From ac3da6826761a799f2e5a6ddafe1cd253cfca099 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:35:38 -0700 Subject: [PATCH 1/2] javascript: a const holding a wrapped handler is registered at its route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const h = catchAsync(async (req, res) => …)` then `router.get('/a', h)` printed the route line as '[by name] … reads it'. A const read on a route line by the callable making the route call is now a registration: [registered] when the wrapper's returned function is what the line hands over, [by name] with the route wording for a library wrapper. router.route(p).get(h) names GET, a file:line on a const targets the const, and a file declaring its own same-named const or function no longer merges in. Both backends (Datalog rules and the SQL fallback). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 9 +++ .../axiomcode/scripts/ax_registration.py | 51 ++++++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 23 +++++- .../skills/axiomcode/scripts/dl/impact.dl | 61 ++++++++++++--- .../skills/axiomcode/scripts/graph_sql.py | 45 ++++++++++- .../wrapped-handler-route/case.json | 74 +++++++++++++++++++ .../src/controllers/adminController.js | 5 ++ .../src/controllers/arrowController.js | 2 + .../src/controllers/libController.js | 3 + .../src/controllers/report.controller.js | 3 + .../wrapped-handler-route/src/routes/admin.js | 9 +++ .../src/routes/chained.js | 8 ++ .../wrapped-handler-route/src/routes/lib.js | 7 ++ .../wrapped-handler-route/src/routes/local.js | 6 ++ .../wrapped-handler-route/src/routes/other.js | 3 + .../src/routes/qualified.js | 7 ++ .../src/routes/routed.js | 7 ++ .../src/services/adminService.js | 3 + .../src/utils/catchAsync.js | 2 + 19 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 tests/cases/javascript/wrapped-handler-route/case.json create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/admin.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/chained.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/lib.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/local.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/other.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/routed.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/services/adminService.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 3ad1d58f1..c83569891 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -479,3 +479,12 @@ def best_cert(certs): def direct_cert(tier): """how sure a DIRECT dependent row is, from the edge's tier.""" return DIRECT_CERT.get(tier, DIRECT_CERT_DEFAULT) + + +def site_lines(start, end, cap=40): + """every line a call site spans. A chained call (`router\\n .route('/')\\n .post(h)`) records its edges on the + statement's first line while an argument sits on a later one. Capped: a site spanning a whole callback body is + not a hand-off on every line of it.""" + start = start or 0 + if not end or end <= start or end - start > cap: return [start] + return list(range(start, end + 1)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6b9f12166..7f896a0dd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,8 +16,11 @@ here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166). """ +import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} +# the verbs that name an HTTP method; `all`, `use` and `route` register or mount without naming one +HTTP_VERB = ROUTE_VERB - {'all', 'use', 'route'} # A CALLBACK IS NOT RECOGNISED BY THE VERB ALONE, and a curated list of verbs was the wrong instrument. # The route leg has two independent signals -- an HTTP verb AND a path-shaped literal on the same line -- and it is @@ -80,7 +83,8 @@ def registrations(q, site_file=None): # handler does depend on it — so only the key is withheld. tf = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} if _has(q, 'symbols') else set() out = {} - for name, site_kind, fp, a, b in q("""SELECT callee_name, kind, file_path, start_line, end_line FROM call_sites + verbs = {} # (file, line) -> {HTTP verb: first column}, for the wording below + for name, site_kind, fp, a, b, col in q("""SELECT callee_name, kind, file_path, start_line, end_line, end_column FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"""): f = sf(fp); b = b or a if b < a or b - a > MAX_SITE_SPAN: @@ -98,9 +102,54 @@ def registrations(q, site_file=None): # a route beats a plain callback on the same line: `router.use('/x', wrap(handler))` is a route site if (f, l) not in out or kind == 'route': out[(f, l)] = (kind, key, why) + if short.lower() in HTTP_VERB and (l == b or a == b): + vs = verbs.setdefault((f, l), {}); vs[short.upper()] = min(vs.get(short.upper(), col or 0), col or 0) + # `router.route('/').get(h)` is two route-shaped calls on one line, and `route` said nothing about the method: the + # line's HTTP verbs name it, in the order written. A chain registering two (`.get(a).post(b)`) names both, because + # a line is all a reference records and it cannot say which argument list the handler sat in + for (f, l), (k, key, w) in list(out.items()): + vs = verbs.get((f, l)) + if k == 'route' and vs: + m = re.match(r'registered as a \w+ route (".*?") here', w) + if m: out[(f, l)] = (k, key, f"registered as a {'/'.join(sorted(vs, key=vs.get))} route {m.group(1)} here — the router calls it, no call site does") return sorted((ref_at.get((f, l), ''), f, l, k, key, w) for (f, l), (k, key, w) in out.items()) +# A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a +# field, not a method, so it has no call edge of its own to carry the route wording. The three facts below are what +# both backends join instead (`const_route` in dl/impact.dl, graph_sql.direct_for_field): + +def route_calls(q, site_file=None): + """{(caller, file, line)}: the callable making a route-shaped call (`get`, `route`, `use` …), on every line the call + spans. A name read on a route line by THAT callable is an argument of the registration; one read inside an inline + handler on the same line (`router.get('/n', (req, res) => res.json(count))`) belongs to the handler, and is a read.""" + import ax_edges + sf = site_file or (lambda x: x) + out = set() + for c, name, fp, a, b in q("""SELECT caller_id, callee_name, file_path, start_line, end_line FROM call_sites + WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): + if name.split('.')[-1].lower() in ROUTE_VERB: + out.update((c, sf(fp), l) for l in ax_edges.site_lines(a, b)) + return out + + +def init_call_lines(q, site_file=None): + """{(file, line)}: lines a call starts on, other than `require(…)`. A const declared on one holds what a call + returned; `const ctrl = require('./ctrl')` holds a module, which is read on a route line, not registered there.""" + sf = site_file or (lambda x: x) + return {(sf(fp), a) for name, fp, a in q("""SELECT callee_name, file_path, start_line FROM call_sites + WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL""") + if name.split('.')[-1] != 'require'} + + +def returned_functions(q): + """{(wrapper, fn)}: the engine's own `ext_return_value` — the function a callable returns. `catchAsync` returns + `(req, res, next) => …`, and that is the function the route line hands over for every const it wrapped.""" + if not _has(q, 'ext_return_value'): + return set() + return {(w, m) for w, k, m in q("SELECT c0, c1, c2 FROM ext_return_value") if k == 'func' and m} + + def value_ref_rows(q, names, ids, site_file=None, at=None, lines=None): """The SQL side of the rule: [(caller, 'uses', why, 'by name', file, line)] for a method target. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e74bbfa1f..dc9129448 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -449,6 +449,12 @@ class Impact: if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: out.append(('type', f"{self.g.sym[tids[0]]['kind']} {self.g.disp(tids[0])}" + (f" (+{len(tids)-1})" if len(tids) > 1 else ''), tids)) + # a const declared ON the line is the declaration written there: the enclosing module spans the line and a + # function in its initializer (`const h = wrap(async (req, res) => …)`) starts on it, and neither is what was asked + # — unless that function IS the const (`const h = async (req, res) => …` is named `h`), which stays the method + if kind is None and out and out[0][0] == 'field' and re.fullmatch(r'.+\.\w+:\d+', s): + named = {self.g.sym[i]['name'] for i in self.methods(s, soft=True) if self.g.sym[i].get('line') == out[0][2][0]['line']} + if not named & {f['name'] for f in out[0][2]}: return out if kind in (None, 'method'): mids = self.methods(s, soft=True) # `Type` written alone is the type, and its construction is Type., so a bare type name must not @@ -522,6 +528,12 @@ class Impact: if soft: return [] die(f"no type named {s}") def field_rows(self, s): + # `file.js:12` — the field or const DECLARED on that line. Split on `.` it named a field `js:12`, so a const was + # the one declaration a file:line could not target, and its bare name answered for every const so named + fl = re.fullmatch(r'(.+\.\w+):(\d+)', s) + if fl: + p, n = fl.group(1), int(fl.group(2)) + return [f for f in self.fields.values() if f['line'] == n and (f['file'] == p or (f['file'] or '').endswith('/' + p))] parts = s.split('.'); name = parts[-1]; owner = '.'.join(parts[:-1]) rows = [f for f in self.fields.values() if f['name'] == name and (not owner or (f['owner'] or '') == owner or (f['owner'] or '').endswith('.' + owner) or (f['qualified_name'] or '').endswith('.' + s))] if not rows and '.' in s: # the dotted spelling, only after the written one @@ -698,7 +710,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '35' # 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '36' # 36: handoff_at, route_call, init_call, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -878,6 +890,15 @@ class Impact: stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) + # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) + # records its edges on the statement's first line, while the handler is named on a later one + W('handoff_at', sorted({(r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']), l) for r in g.q( + """SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL AND s.file_path IS NOT NULL""") + for l in ax_edges.site_lines(r['start_line'], r['end_line'])})) + W('route_call', sorted(ax_registration.route_calls(g.q, g.site_file))) + W('init_call', sorted(ax_registration.init_call_lines(g.q, g.site_file))) + W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) # is the kind "stub": still a name match for the rows, never a by-name seed of the closure diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 970c8da10..9326ec00a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -39,6 +39,7 @@ .decl edge(a:symbol, b:symbol, t:symbol) .input edge .decl named(n:symbol, m:symbol) .input named .decl calls(c:symbol, m:symbol, tier:symbol, f:symbol, l:number) .input calls +.decl handoff_at(c:symbol, m:symbol, f:symbol, l:number) .input handoff_at .decl unresolved(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input unresolved .decl named_site(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input named_site .decl member(t:symbol, s:symbol, n:symbol, k:symbol) .input member @@ -426,6 +427,39 @@ fref(q, c, rk, f, l) :- enum_member_target(q, fl), field(fl, t, n, ff, fll), ref .decl frole(rk:symbol, role:symbol, why:symbol) frole("qualified", "uses", "writes/reads it"). frole("bare", "reads", "reads it"). +// A CONST THAT HOLDS A WRAPPED HANDLER is registered where it is handed to a route, not read there. +// `const listAdmins = catchAsync(async (req, res) => …)` then `router.get('/admins', listAdmins)`: the declaration is +// a const, not a method, so the method-target route wording (`route_site` above) never applied and the answer said +// only "[by name] … reads it" at the registration. Both legs need the name read on a route line BY THE CALLABLE +// MAKING THE ROUTE CALL (`route_call`): a read inside an inline handler on that line +// (`router.get('/n', (req, res) => res.json(count))`) is attributed to the handler, and stays a read. +// - registered: the declaration line calls a wrapper, the engine says what function that wrapper returns +// (`returns_fn`), and the route line hands THAT function over (`handoff_at`, on every line the call spans, since a +// chained registration records its edges on the statement's first line and names the handler on a later one). +// A hand-off of some other function on the same line (`.post(protect, admin, h)` hands `admin`) is not evidence. +// - by name: the wrapper is a library's, so nothing says what it returns — the const holds a call's result +// (`init_call`, not a `require`) and is an argument of the registration. The route's wording, the name's rung, +// as for a method named on a route line with no edge (`registered` above). +// Bare (`router.get('/a', listAdmins)`) or qualified through the module (`.post(auth(), userController.createUser)`). +.decl route_call(c:symbol, f:symbol, l:number) .input route_call +.decl init_call(f:symbol, l:number) .input init_call +.decl returns_fn(w:symbol, m:symbol) .input returns_fn +.decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) +const_route_edge(q, c, m, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), calls(_, w, _, ff, fll), returns_fn(w, m), + fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), handoff_at(c, m, f, l). +.decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) +const_route(q, c, cert, f, l) :- const_route_edge(q, c, _, f, l), cert_tier("callback_registered", cert, _). +direct(q, c, "uses", why, cert, f, l) :- const_route(q, c, cert, f, l), registration(_, f, l, "route", _, why). +// the row claims the hand-off edge on that line, so `verified:` looks it up like any registered row +direct_edge(q, c, m) :- const_route_edge(q, c, m, _, _). +.decl const_route_byname(q:symbol, c:symbol, f:symbol, l:number) +const_route_byname(q, c, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), init_call(ff, fll), + fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), !const_route(q, c, _, f, l). +direct(q, c, "uses", why, "by name", f, l) :- const_route_byname(q, c, f, l), registration(_, f, l, "route", _, why). +.decl const_routed(q:symbol, c:symbol, f:symbol, l:number) +const_routed(q, c, f, l) :- const_route(q, c, _, f, l). +const_routed(q, c, f, l) :- const_route_byname(q, c, f, l). + // A field access the ENGINE resolved. The site is bound to THIS declaration, so it is not a name // match against every field of that name, and its direction is known. Sites the engine could not // bind (tier ambiguous_unknown) are left to the name rules below, which is where they belong. @@ -442,15 +476,15 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl fa_known(q, c) :- target(q, "field", fl, _), fa_bound(c, fl, _, _, _). fa_known(q, c) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(c, fl2, _, _, _), fl2 != fl, field(fl2, _, n, _, _). // in scope: inside the owner, a subtype, or a nested type — or the type node itself (a field initializer) -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c). -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c), !const_routed(q, c, f, l). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c), !const_routed(q, c, f, l). // qualified elsewhere: the qualifier written on the line decides — the owner's own name confirms, another type's name excludes direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c). + owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and // TypeScript is always there — and in Python and JavaScript a module-level function has no owner at all, so a // qualified read of a field from one was matched by no rule and vanished from the answer. `price_order` reading @@ -458,15 +492,22 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie // own class touched it. The qualifier decides here exactly as it does above: the owner's own name confirms, // another TYPE's name excludes, and a variable (which is what a Python qualifier usually is) is `by name`. direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c). + !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, _, _, _), !owner(c, _), !scope(t, c). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). +// a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in +// one module and a wrapped handler of the same name in another are two declarations, and each answer carried the +// other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` +// in a service that declares `const createUser = async (body) => …` is not a reader of the controller's wrapped handler +.decl shadowed_in(fl:symbol, n:symbol, f:symbol) +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, _, _), field(fl2, _, n, f, _), fl2 != fl. +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, ff, _), named(n, m), decl_file(m, f), f != ff. // a declared accessor is the field's door. The door is a CALL, so it carries the call's tier like every other // call-derived rule here: a site the engine could not narrow to one declaration is `one of a set`, not `resolved`. // Binding the tier as a wildcard and hardcoding "resolved" made the accessor door the one place in this file that diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 9c6cd0dec..decfcc0ae 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1939,6 +1939,28 @@ def direct_for_field(q, fids, at, code, lines, inside, rel): for f_ in fids: r_ = field_rec(q, f_) if r_ and r_[1]: decl_sites.setdefault(r_[1], set()).add((r_[2], r_[3])) + # const_route / const_route_byname in dl/impact.dl: a const read on a route line by the callable making the route + # call. Registered when its declaration line calls a wrapper whose returned function (`returns_fn`) that line hands + # over; by name when the declaration holds some other call's result. Like the rules, keyed on the QUERY: any target + # declaration qualifies the site. + route_why, rcall, handed_to, ret_of, dcalls, inits = {}, set(), collections.defaultdict(set), collections.defaultdict(set), set(), set() + if decl_sites: + inits = ax_registration.init_call_lines(q, rel) + dsites = {s for ss in decl_sites.values() for s in ss} + if dsites & inits: # init_call(ff,fll) on ANY target declaration + for _d, rf_, rl_, k_, _key, w_ in ax_registration.registrations(q, rel): + if k_ == 'route': route_why[(rf_, rl_)] = w_ + if route_why: + rcall = ax_registration.route_calls(q, rel) + for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) + for w_, fp_, l_ in q("""SELECT e.callee_method_id, s.file_path, s.start_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if (rel(fp_) if fp_ else '', l_ or 0) in dsites: dcalls.update(ret_of.get(w_, ())) # calls(_,w,_,ff,fll), returns_fn(w,m) + for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if m_ in dcalls: + for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at + reg_cert = ax_edges.direct_cert('callback_registered') for fid in fids: rec = field_rec(q, fid) if not rec: continue @@ -1986,8 +2008,20 @@ def declares_of(s_): # scope can be both `in scope` (231, the qualifier is the owner's own type name) and `by name` (233, some # other qualifier). Written as an if/elif chain this picked one and lost 19 rows on one field alone. typenames = {r[0] for r in q("SELECT name FROM symbols WHERE type_id IS NOT NULL AND name IS NOT NULL")} + # shadowed_in(fl,n,f): the files declaring ANOTHER field of this name, or a function of this name other than + # in the field's own file, whose bare reads are their own + own_decl = {r[0] for r in q("""SELECT file FROM symbols WHERE name = ? AND rowid <> ? AND method_id IS NULL AND type_id IS NULL + AND kind IN ('field','const','enum_member','variable')""", n, rid)} + own_decl |= {r[0] for r in q("SELECT file FROM symbols WHERE name = ? AND method_id IS NOT NULL AND file IS NOT NULL", n) if r[0] != ff} for c, rk, rf, rl, _e in fref: s_ = owner_of.get(c) + if (rf, rl) in route_why and (c, rf, rl) in rcall: # const_route*: bare or qualified + if handed_to.get((c, rf, rl)): + rows.append((c, 'uses', route_why[(rf, rl)], reg_cert, rf, rl)) + de += [(c, m_) for m_ in handed_to[(c, rf, rl)]] # direct_edge(q,c,m) :- const_route_edge + continue + rows.append((c, 'uses', route_why[(rf, rl)], 'by name', rf, rl)) # const_route_byname + continue role, why = ('uses', 'writes/reads it') if rk == 'qualified' else ('reads', 'reads it') in_scope = (s_ is not None and s_ in scope) or (c in scope) if in_scope: # 228 / 229 @@ -2002,10 +2036,19 @@ def declares_of(s_): rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if not quals: # 235 — no qualifier on the line rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + # the same in a callable with no owner type (a module, a module-level function): `!owner(c, _)` rules + if rk == 'qualified' and s_ is None and c not in scope: + quals = _qualifiers(code, rf, rl, n) + if tname and tname in quals: + rows.append((c, 'uses', 'writes/reads it', 'in scope', rf, rl)) + if tname and any(qn != tname and qn not in typenames and qn not in SELF_QUALIFIERS for qn in quals): + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + if not quals: + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if rk == 'bare': if s_ is not None and s_ not in scope and n not in declares_of(s_): # 238 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) - if s_ is None and c not in scope: # 240 + if s_ is None and c not in scope and rf not in own_decl: # 240 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) # ── the accessors the convention gives the field, and their callers (242 / 243) ──────────────────── for an, role_ in _accessors(n): diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json new file mode 100644 index 000000000..0fd3ee3d2 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -0,0 +1,74 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a handler written as `const listAdmins = catchAsync(async (req, res) => …)` is a const, not a method, so the route wording the method target gets never applied: the registration `router.get('/admins', listAdmins)` was printed as '[by name] … reads it'. The const's declaration hands a function to the wrapper and the route line hands one to the router, both engine edges", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["const listAdmins", "[registered] admin.", "registered as a GET route \"/admins\"", "src/routes/admin.js:5"], + "avoid": ["[by name] admin. src/routes/admin.js:5"]}, + {"why": "the same when the const is declared in the file that registers it: the read was [in scope] and still not a route", + "run": ["impact", "localList"], + "want": ["[registered] local.", "registered as a GET route \"/local\""], + "avoid": ["reads it"]}, + {"why": "a file:line on a const targets the const. It used to resolve to the enclosing (other.js:2) or to the arrow in the initializer (adminController.js:2), so a const could only be asked by its bare name, which answers for every const so named", + "run": ["impact", "src/routes/other.js:2"], + "want": ["const listAdmins [field]", "[in scope] other. src/routes/other.js:3"], + "avoid": ["other. (at src/routes/other.js:2)", "registered as a GET route"]}, + {"why": "and a file that declares its own const of the name reads its own, not the wrapped handler's: other.js's `['root']` is not a reader of the controller's listAdmins", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["other. src/routes/other.js:3"]}, + {"why": "CONTROL: a plain function handler keeps the wording it already had, from the method rule", + "run": ["impact", "plainAdmins"], + "want": ["[registered] admin.", "registered as a GET route \"/admins-plain\""]}, + {"why": "CONTROL: `const arrowAdmins = async (req, res) => …` IS the function, so its file:line stays the method, with the method's route row", + "run": ["impact", "src/controllers/arrowController.js:1"], + "want": ["arrowAdmins (at src/controllers/arrowController.js:1) [method]", "registered as a GET route \"/admins-arrow\""]}, + {"why": "CONTROL: a const that holds a value, read inside an inline handler on a route line, is a read — the inline arrow hands nothing over and the const's initializer is not a function", + "run": ["impact", "adminCount"], + "want": ["[by name] src/routes/admin.js:7 — reads it"], + "avoid": ["registered as a"]}, + {"why": "the handler read through its module (`.get(auth(), adminController.listAdmins)` after `.route(…)`) is registered there too: it was '[by name] … writes/reads it'", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] qualified.", "src/routes/qualified.js:5"], + "avoid": ["writes/reads it"]}, + {"why": "a chained registration split over lines (`router\\n .route('/admins-c')\\n .get(auth(), adminController.listAdmins)`) records its hand-off on the statement's first line and names the handler two lines down: registered there too", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] chained.", "src/routes/chained.js:7"], + "avoid": ["chained. src/routes/chained.js:7 — writes/reads it"]}, + {"why": "a service that declares its own function of the name reads its own in its exports, not the controller's const", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["src/services/adminService.js"]}, + {"why": "a file:line in a file whose name carries a dot (`report.controller.js`) is a file and a line: the dots were read as a package prefix and every such target was refused", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["const listReports [field]"], + "avoid": ["matches no package"]}, + {"why": "CONTROL: a file:line in a file that is not in the graph is still refused", + "run": ["impact", "src/controllers/nope.controller.js:2"], + "expect_error": true, + "avoid": ["[field]", "[method]"]}, + {"why": "the registered row claims the hand-off edge on the route line, so `verified:` finds it: it printed '✗ 1 not found' for every such row", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["all present"], + "avoid": ["not found"]}, + {"why": "`router.route('/reports').get(listReports)` is a GET route: the `route` call on the same line named the method ROUTE", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["[registered] routed.", "registered as a GET route \"/reports\""], + "avoid": ["ROUTE route", "not found"]}, + {"why": "a chain registering two methods on one line names both: a reference records a line, not the argument list it sat in", + "run": ["impact", "plainAdmins"], + "want": ["registered as a GET/POST route \"/both\""], + "avoid": ["ROUTE route"]}, + {"why": "a handler wrapped by a LIBRARY (`asyncHandler` from a package) has no engine edge for what the wrapper returns, so the row keeps the name's rung but says what the line does: it was '[by name] … reads it'", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["[by name] lib. src/routes/lib.js:5 — registered as a GET route \"/lib\""], + "avoid": ["src/routes/lib.js:5 — reads it", "[registered]"]}, + {"why": "CONTROL: a line that also hands over a plain function (`.post(libList, plainAdmins)`) is not evidence for the const: the hand-off is plainAdmins', so the const's row stays [by name]", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["[by name] lib. src/routes/lib.js:6 — registered as a POST route \"/lib-mixed\""], + "avoid": ["[registered] lib."]}, + {"why": "CONTROL: a const holding a module (`const adminController = require(…)`) read on a route line is read there, not registered", + "run": ["impact", "src/routes/chained.js:2"], + "avoid": ["registered as a"]}, + {"why": "CONTROL: a qualified read of a const holding a value, off any route line, stays a read", + "run": ["impact", "adminCount"], + "want": ["src/routes/qualified.js:6 — writes/reads it"], + "avoid": ["registered as a"]} + ]} diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js new file mode 100644 index 000000000..da2033b3b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js @@ -0,0 +1,5 @@ +const catchAsync = require('../utils/catchAsync'); +const listAdmins = catchAsync(async (req, res) => { res.json([]); }); +async function plainAdmins(req, res) { res.json([]); } +const adminCount = 3; +module.exports = { listAdmins, plainAdmins, adminCount }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js new file mode 100644 index 000000000..84bb86847 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js @@ -0,0 +1,2 @@ +const arrowAdmins = async (req, res) => { res.json([]); }; +module.exports = { arrowAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js new file mode 100644 index 000000000..3c0dad615 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js @@ -0,0 +1,3 @@ +const asyncHandler = require('express-async-handler'); +const libList = asyncHandler(async (req, res) => { res.json([]); }); +module.exports = { libList }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js new file mode 100644 index 000000000..d719d3960 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js @@ -0,0 +1,3 @@ +const catchAsync = require('../utils/catchAsync'); +const listReports = catchAsync(async (req, res) => { res.json([]); }); +module.exports = { listReports }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js new file mode 100644 index 000000000..d87b78303 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js @@ -0,0 +1,9 @@ +const express = require('express'); +const { listAdmins, plainAdmins, adminCount } = require('../controllers/adminController'); +const { arrowAdmins } = require('../controllers/arrowController'); +const router = express.Router(); +router.get('/admins', listAdmins); +router.get('/admins-plain', plainAdmins); +router.get('/admins-count', (req, res) => res.json(adminCount)); +router.get('/admins-arrow', arrowAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js new file mode 100644 index 000000000..8e3af8eb9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js @@ -0,0 +1,8 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router + .route('/admins-c') + .get(auth(), adminController.listAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js new file mode 100644 index 000000000..eefdaca47 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { libList } = require('../controllers/libController'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.get('/lib', libList); +router.route('/lib-mixed').post(libList, plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/local.js b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js new file mode 100644 index 000000000..2d9c0ccd3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js @@ -0,0 +1,6 @@ +const express = require('express'); +const catchAsync = require('../utils/catchAsync'); +const router = express.Router(); +const localList = catchAsync(async (req, res) => { res.json([]); }); +router.get('/local', localList); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/other.js b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js new file mode 100644 index 000000000..59464624c --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js @@ -0,0 +1,3 @@ +// an unrelated const of the same name: it is not the handler registered in admin.js +const listAdmins = ['root']; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js new file mode 100644 index 000000000..7d13ff4c0 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js @@ -0,0 +1,7 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router.route('/admins-q').get(auth(), adminController.listAdmins); +const limit = adminController.adminCount; +module.exports = { router, limit }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js new file mode 100644 index 000000000..b75ba9086 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { listReports } = require('../controllers/report.controller'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.route('/reports').get(listReports); +router.route('/both').get(plainAdmins).post(plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js new file mode 100644 index 000000000..fb6ae185f --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js @@ -0,0 +1,3 @@ +// a service function of the same name: its own export is not a reader of the controller's wrapped handler +const listAdmins = async () => []; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js new file mode 100644 index 000000000..7ca747ca3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js @@ -0,0 +1,2 @@ +const catchAsync = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +module.exports = catchAsync; From a7274096e16e090e22431ea78fe0d19d53ab3685 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:23:21 -0700 Subject: [PATCH 2/2] javascript: only a function handed in a handler position is registered at a route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The const rule labelled any const read on a route line by the module making the call: the router receiving `router.get(…)`, the app receiving `app.use(…)`, a schema passed to `validate(bodySchema)`, `__dirname` inside `express.static(…)`, and TypeBox schemas inside a route's options object all read "registered as a route". A const is now registered only when both hold: - it is written in a handler position of the route call: a top-level argument, or an element of an array argument, bare or at the end of a member chain. Not the receiver, not inside an options object, not an argument of a nested call (`route_arg`, read from the call site's own argument list); - it holds a function: a function, an alias of a declared function, or a call that returns one (a callee the engine says returns a function, or a wrapper handed a function, nested wrappers included). A schema builder, a plain object, `express.Router()`, `require(…)`, a string or a number never does, so a router mounted with `app.use('/p', router)` stays a mount (`callable_const`). [registered] still needs the route line to hand over the function the const holds; an alias and a declaration whose wrapper call starts on the next line now reach it too. Both backends; TypeScript cases added. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/ax_registration.py | 163 ++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-impact | 11 +- .../skills/axiomcode/scripts/dl/impact.dl | 42 +++-- .../skills/axiomcode/scripts/graph_sql.py | 40 +++-- .../wrapped-handler-route/case.json | 43 ++++- .../wrapped-handler-route/src/app.js | 9 + .../src/middlewares/validate.js | 2 + .../src/routes/validated.js | 22 +++ .../wrapped-handler-route/case.json | 33 ++++ .../wrapped-handler-route/src/app.ts | 4 + .../src/controllers/userController.ts | 5 + .../wrapped-handler-route/src/routes/tasks.ts | 20 +++ .../wrapped-handler-route/src/routes/users.ts | 7 + .../wrapped-handler-route/src/schemas/task.ts | 4 + .../wrapped-handler-route/src/server.ts | 4 + .../src/utils/catchAsync.ts | 2 + 16 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 tests/cases/javascript/wrapped-handler-route/src/app.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/validated.js create mode 100644 tests/cases/typescript/wrapped-handler-route/case.json create mode 100644 tests/cases/typescript/wrapped-handler-route/src/app.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/routes/users.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/server.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 7f896a0dd..028c113df 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -116,30 +116,157 @@ def registrations(q, site_file=None): # A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a -# field, not a method, so it has no call edge of its own to carry the route wording. The three facts below are what -# both backends join instead (`const_route` in dl/impact.dl, graph_sql.direct_for_field): - -def route_calls(q, site_file=None): - """{(caller, file, line)}: the callable making a route-shaped call (`get`, `route`, `use` …), on every line the call - spans. A name read on a route line by THAT callable is an argument of the registration; one read inside an inline - handler on the same line (`router.get('/n', (req, res) => res.json(count))`) belongs to the handler, and is a read.""" - import ax_edges +# field, not a method, so it has no call edge of its own to carry the route wording. What both backends join instead +# (`const_route` in dl/impact.dl, graph_sql.direct_for_field) is two facts read from the source at the call sites the +# engine recorded, and neither is "the name appears on a route line": +# +# route_args the name is written IN A HANDLER POSITION of the route call: a top-level argument after the path +# (or an element of an array argument, which a router flattens) that is a bare name or a member +# chain ending in it. The receiver (`router.get`), an options object (`{ schema: { 200: S } }`), an +# argument of a nested call (`validate(bodySchema)`, `express.static(dir)`) and the path are not +# handed to the router to call, and a name written there is read, not registered. +# const_values the const HOLDS A FUNCTION: its initializer is a function, a reference to a declared function, or +# a call that returns one — a callee the engine says returns a function (`returns_fn`), or a wrapper +# that is handed a function (`asyncHandler(async (req, res) => …)`, nested wrappers too). A plain +# object, a schema builder, `express.Router()`, `require(…)`, a string or a number never is: a router +# mounted with `app.use('/p', router)` is a mount, not a handler. + +_IDENT = r'[A-Za-z_$][\w$]*' +_CHAIN = re.compile(rf'({_IDENT})(?:\s*\??\.\s*({_IDENT}))*\s*$') +_OPEN, _CLOSE = '([{', ')]}' + + +def _match(text, i): + """the index just past the bracket group opening at text[i] (strings and comments are already blanked)""" + d = 0 + for j in range(i, len(text)): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: + d -= 1 + if d == 0: return j + 1 + return len(text) + + +def _split_args(text, lo, hi): + """[(start, end)] of the top-level comma-separated items in text[lo:hi]""" + out, d, s = [], 0, lo + for j in range(lo, hi): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: d -= 1 + elif c == ',' and d == 0: out.append((s, j)); s = j + 1 + if text[s:hi].strip(): out.append((s, hi)) + return out + + +def _fn_literal(t): + """`function …`, `async (a, b) => …`, `req => …`, `(req: Request): void => …`""" + t = t.lstrip() + if re.match(r'(async\s+)?function\b', t): return True + m = re.match(r'(async\s*)?', t); t = t[m.end():] + if re.match(rf'{_IDENT}\s*=>', t): return True + if t.startswith('('): + return bool(re.match(r'\s*(:[^=;{}]*)?=>', t[_match(t, 0):])) + return False + + +def route_args(q, code, site_file=None): + """{(caller, file, line, name)}: `name` written in a handler position of a route-shaped call made by `caller`.""" + if not _has(q, 'call_sites') or code is None: + return set() sf = site_file or (lambda x: x) out = set() - for c, name, fp, a, b in q("""SELECT caller_id, callee_name, file_path, start_line, end_line FROM call_sites - WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): - if name.split('.')[-1].lower() in ROUTE_VERB: - out.update((c, sf(fp), l) for l in ax_edges.site_lines(a, b)) + for c, name, fp, a, ac, b, bc in q("""SELECT caller_id, callee_name, file_path, start_line, start_column, end_line, end_column + FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): + if name.split('.')[-1].lower() not in ROUTE_VERB: continue + b = b or a + if b < a or b - a > MAX_SITE_SPAN: continue + f = sf(fp); L = code(f) or [] + if b > len(L): continue + text = '\n'.join(L[a - 1:b]); starts = [0] + for ln in L[a - 1:b - 1]: starts.append(starts[-1] + len(ln) + 1) + end = starts[-1] + (bc - 1 if bc else len(L[b - 1])) # end_column is one past the call's `)` + close = text.rfind(')', 0, end) + if close < 0: continue + d, j = 0, close # back to the `(` that opens THIS call's arguments + while j >= 0: + if text[j] in _CLOSE: d += 1 + elif text[j] in _OPEN: + d -= 1 + if d == 0: break + j -= 1 + if j < 0: continue + line_of = lambda k: a + sum(1 for s0 in starts[1:] if s0 <= k) + # every argument is looked at: the path is a string (blanked), and a path or prefix held in a const is not a + # function, which `const_values` decides. `.route('/p').post(a, b)` has no path in its own argument list + todo = _split_args(text, j + 1, close) + while todo: + lo, hi = todo.pop() + t = text[lo:hi]; st = t.strip() + if st.startswith('[') and st.endswith(']'): # an array of handlers: the router flattens it + k = lo + t.index('['); todo += _split_args(text, k + 1, lo + t.rindex(']')); continue + m = _CHAIN.match(st) + if not m or st.startswith(('...', 'new ')): continue + last = re.search(rf'({_IDENT})\s*$', st) + out.add((c, f, line_of(lo + t.rindex(last.group(1))), last.group(1))) return out -def init_call_lines(q, site_file=None): - """{(file, line)}: lines a call starts on, other than `require(…)`. A const declared on one holds what a call - returned; `const ctrl = require('./ctrl')` holds a module, which is read on a route line, not registered there.""" +def const_values(q, code, decls, site_file=None): + """For each (file, line, name) const declaration: ({(file, line)} that hold a function, {(file, line, wrapper)} the + client callable its initializer calls, {(file, line, name)} the function it is an alias of).""" + callable_, wrap, alias = set(), set(), set() + if not decls or code is None: + return callable_, wrap, alias sf = site_file or (lambda x: x) - return {(sf(fp), a) for name, fp, a in q("""SELECT callee_name, file_path, start_line FROM call_sites - WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL""") - if name.split('.')[-1] != 'require'} + fns = {n for (n,) in q("SELECT DISTINCT name FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL AND name NOT LIKE '<%'")} + rets = {w for w, _m in returned_functions(q)} + callee_at = {} + for fp, l, col, m in q("""SELECT s.file_path, s.start_line, s.start_column, e.callee_method_id FROM call_sites s JOIN call_edges e + ON e.call_site_id = s.id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL + AND s.file_path IS NOT NULL AND s.start_line > 0"""): + callee_at.setdefault((sf(fp), l, col), set()).add(m) + + def call_value(text, i, f, a, starts, depth=0): + """(callable?, wrappers) for the expression at text[i:]""" + m = re.compile(rf'\s*(await\s+|new\s+)?({_IDENT}(?:\s*\??\.\s*{_IDENT})*)\s*').match(text, i) + if not m or m.group(1): return False, set() + k = m.end(); chain = m.group(2); k0 = m.start(2) + ln = a + sum(1 for s0 in starts[1:] if s0 <= k0); col = k0 - starts[ln - a] + 1 + ws = callee_at.get((f, ln, col), set()) + if k >= len(text) or text[k] != '(': # a reference: an alias of a declared function + last = re.split(r'\s*\??\.\s*', chain)[-1] + return (last in fns and depth == 0 and not re.match(r'\s*[\[+\-*/%?`]', text[k:k + 2]), {('alias', last)}) + if chain.split('.')[-1].strip() in ('require', 'import'): return False, set() + ok = bool(ws & rets); groups = 0 + while k < len(text) and text[k] == '(' and groups < 3: # `wrap(fn)` and curried `wrap(opts)(fn)` + e = _match(text, k); groups += 1 + for lo, hi in _split_args(text, k + 1, e - 1): + t = text[lo:hi] + if _fn_literal(t): ok = True + elif depth < 3: + inner, _w = call_value(text, lo, f, a, starts, depth + 1) + if inner or (re.fullmatch(rf'\s*{_IDENT}(\s*\.\s*{_IDENT})*\s*', t) and re.split(r'\s*\.\s*', t.strip())[-1] in fns): ok = True + k = e + while k < len(text) and text[k] in ' \t': k += 1 + if k < len(text) and text[k] == '.': return False, set() # `express.Router().use(…)`: a method of the result + return ok, {('wrap', w) for w in ws} + + for f, l, n in decls: + L = code(f) or [] + if not (0 < l <= len(L)): continue + chunk = L[l - 1:l + 40]; text = '\n'.join(chunk); starts = [0] + for ln in chunk[:-1]: starts.append(starts[-1] + len(ln) + 1) + m = re.search(rf'(?])', text[:len(chunk[0])]) + if not m: continue + if _fn_literal(text[m.end():]): callable_.add((f, l)); continue + ok, how = call_value(text, m.end(), f, l, starts) + if ok: callable_.add((f, l)) + for kind, x in how: + if kind == 'wrap': wrap.add((f, l, x)) + elif ok: alias.add((f, l, x)) + return callable_, wrap, alias def returned_functions(q): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index dc9129448..3ee75e00c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -710,7 +710,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '36' # 36: handoff_at, route_call, init_call, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '36' # 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -896,8 +896,13 @@ class Impact: """SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL AND s.file_path IS NOT NULL""") for l in ax_edges.site_lines(r['start_line'], r['end_line'])})) - W('route_call', sorted(ax_registration.route_calls(g.q, g.site_file))) - W('init_call', sorted(ax_registration.init_call_lines(g.q, g.site_file))) + # a name in a handler position of a route call, and the consts of those names that hold a function + rargs = ax_registration.route_args(g.q, self.code, g.site_file) + rnames = {n for *_x, n in rargs} + fcall, fwrap, falias = ax_registration.const_values(g.q, self.code, sorted({(f['file'], f['line'], f['name']) for f in self.fields.values() + if f['name'] in rnames and f.get('file') and f.get('line')}), g.site_file) + W('route_arg', sorted(rargs)) + W('callable_const', sorted(fcall)); W('init_wrapper', sorted(fwrap)); W('init_alias', sorted(falias)) W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 9326ec00a..60036a945 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -430,31 +430,39 @@ frole("qualified", "uses", "writes/reads it"). frole("bare", "reads", "reads it" // A CONST THAT HOLDS A WRAPPED HANDLER is registered where it is handed to a route, not read there. // `const listAdmins = catchAsync(async (req, res) => …)` then `router.get('/admins', listAdmins)`: the declaration is // a const, not a method, so the method-target route wording (`route_site` above) never applied and the answer said -// only "[by name] … reads it" at the registration. Both legs need the name read on a route line BY THE CALLABLE -// MAKING THE ROUTE CALL (`route_call`): a read inside an inline handler on that line -// (`router.get('/n', (req, res) => res.json(count))`) is attributed to the handler, and stays a read. -// - registered: the declaration line calls a wrapper, the engine says what function that wrapper returns -// (`returns_fn`), and the route line hands THAT function over (`handoff_at`, on every line the call spans, since a -// chained registration records its edges on the statement's first line and names the handler on a later one). -// A hand-off of some other function on the same line (`.post(protect, admin, h)` hands `admin`) is not evidence. -// - by name: the wrapper is a library's, so nothing says what it returns — the const holds a call's result -// (`init_call`, not a `require`) and is an argument of the registration. The route's wording, the name's rung, -// as for a method named on a route line with no edge (`registered` above). -// Bare (`router.get('/a', listAdmins)`) or qualified through the module (`.post(auth(), userController.createUser)`). -.decl route_call(c:symbol, f:symbol, l:number) .input route_call -.decl init_call(f:symbol, l:number) .input init_call +// only "[by name] … reads it" at the registration. Two conditions, both required, both read by ax_registration: +// - `route_arg(c, f, l, n)`: the name is written IN A HANDLER POSITION of a route call `c` makes — a top-level +// argument or an element of an array argument, bare or at the end of a member chain. Not the receiver +// (`router.get`), not inside an options object (`{ schema: { 200: TaskSchema } }`), not an argument of a nested +// call (`validate(bodySchema)`), and not inside an inline handler, whose reads are the handler's. +// - `callable_const(ff, fll)`: the const HOLDS A FUNCTION — a function, a reference to a declared one, or a call that +// returns one (a callee the engine says returns a function, or a wrapper handed a function). A schema, an options +// object, `express.Router()` (a router mounted with `app.use('/p', router)` is a mount) or a module never does. +// Then: +// - registered: the function the const holds is known — the wrapper's returned function (`init_wrapper`, +// `returns_fn`) or the function it aliases (`init_alias`) — and the route line hands THAT function over +// (`handoff_at`). A hand-off of some other function on the same line (`.post(protect, admin, h)`) is not evidence. +// - by name: otherwise (a library wrapper says nothing about what it returns). The route's wording, the name's rung. +.decl route_arg(c:symbol, f:symbol, l:number, n:symbol) .input route_arg +.decl callable_const(f:symbol, l:number) .input callable_const +.decl init_wrapper(f:symbol, l:number, w:symbol) .input init_wrapper +.decl init_alias(f:symbol, l:number, n:symbol) .input init_alias .decl returns_fn(w:symbol, m:symbol) .input returns_fn +.decl const_holds(ff:symbol, fll:number, m:symbol) +const_holds(ff, fll, m) :- init_wrapper(ff, fll, w), returns_fn(w, m). +const_holds(ff, fll, m) :- init_alias(ff, fll, n), named(n, m). +.decl const_handed(q:symbol, c:symbol, f:symbol, l:number, ff:symbol, fll:number) +const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), + fref(q, c, _, f, l), route_site(f, l), route_arg(c, f, l, n). .decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) -const_route_edge(q, c, m, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), calls(_, w, _, ff, fll), returns_fn(w, m), - fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), handoff_at(c, m, f, l). +const_route_edge(q, c, m, f, l) :- const_handed(q, c, f, l, ff, fll), const_holds(ff, fll, m), handoff_at(c, m, f, l). .decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) const_route(q, c, cert, f, l) :- const_route_edge(q, c, _, f, l), cert_tier("callback_registered", cert, _). direct(q, c, "uses", why, cert, f, l) :- const_route(q, c, cert, f, l), registration(_, f, l, "route", _, why). // the row claims the hand-off edge on that line, so `verified:` looks it up like any registered row direct_edge(q, c, m) :- const_route_edge(q, c, m, _, _). .decl const_route_byname(q:symbol, c:symbol, f:symbol, l:number) -const_route_byname(q, c, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), init_call(ff, fll), - fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), !const_route(q, c, _, f, l). +const_route_byname(q, c, f, l) :- const_handed(q, c, f, l, _, _), !const_route(q, c, _, f, l). direct(q, c, "uses", why, "by name", f, l) :- const_route_byname(q, c, f, l), registration(_, f, l, "route", _, why). .decl const_routed(q:symbol, c:symbol, f:symbol, l:number) const_routed(q, c, f, l) :- const_route(q, c, _, f, l). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index decfcc0ae..70a75e3fe 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1939,27 +1939,29 @@ def direct_for_field(q, fids, at, code, lines, inside, rel): for f_ in fids: r_ = field_rec(q, f_) if r_ and r_[1]: decl_sites.setdefault(r_[1], set()).add((r_[2], r_[3])) - # const_route / const_route_byname in dl/impact.dl: a const read on a route line by the callable making the route - # call. Registered when its declaration line calls a wrapper whose returned function (`returns_fn`) that line hands - # over; by name when the declaration holds some other call's result. Like the rules, keyed on the QUERY: any target - # declaration qualifies the site. - route_why, rcall, handed_to, ret_of, dcalls, inits = {}, set(), collections.defaultdict(set), collections.defaultdict(set), set(), set() + # const_handed / const_route / const_route_byname in dl/impact.dl: a const written in a HANDLER POSITION of a route + # call (`route_arg`) whose declaration holds a function (`callable_const`). Registered when the function it holds + # (`init_wrapper` + `returns_fn`, or `init_alias`) is what that line hands over; by name otherwise. Like the rules, + # keyed on the QUERY: any target declaration qualifies the site. + route_why, rargs, handed_to, holds, callable_names = {}, set(), collections.defaultdict(set), set(), set() if decl_sites: - inits = ax_registration.init_call_lines(q, rel) - dsites = {s for ss in decl_sites.values() for s in ss} - if dsites & inits: # init_call(ff,fll) on ANY target declaration + rargs = ax_registration.route_args(q, code, rel) + decls = sorted({(f_, l_, n_) for n_, ss in decl_sites.items() if n_ in {x[3] for x in rargs} for f_, l_ in ss}) + fcall, fwrap, falias = ax_registration.const_values(q, code, decls, rel) + callable_names = {n_ for f_, l_, n_ in decls if (f_, l_) in fcall} + if callable_names: for _d, rf_, rl_, k_, _key, w_ in ax_registration.registrations(q, rel): if k_ == 'route': route_why[(rf_, rl_)] = w_ - if route_why: - rcall = ax_registration.route_calls(q, rel) - for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) - for w_, fp_, l_ in q("""SELECT e.callee_method_id, s.file_path, s.start_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id - WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): - if (rel(fp_) if fp_ else '', l_ or 0) in dsites: dcalls.update(ret_of.get(w_, ())) # calls(_,w,_,ff,fll), returns_fn(w,m) - for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id - WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): - if m_ in dcalls: - for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at + ret_of = collections.defaultdict(set) + for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) + for f_, l_, w_ in fwrap: + if (f_, l_) in fcall: holds.update(ret_of.get(w_, ())) # const_holds :- init_wrapper, returns_fn + for f_, l_, an_ in falias: # const_holds :- init_alias, named + if (f_, l_) in fcall: holds.update(m_ for (m_,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL", an_)) + for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if m_ in holds: + for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at reg_cert = ax_edges.direct_cert('callback_registered') for fid in fids: rec = field_rec(q, fid) @@ -2015,7 +2017,7 @@ def declares_of(s_): own_decl |= {r[0] for r in q("SELECT file FROM symbols WHERE name = ? AND method_id IS NOT NULL AND file IS NOT NULL", n) if r[0] != ff} for c, rk, rf, rl, _e in fref: s_ = owner_of.get(c) - if (rf, rl) in route_why and (c, rf, rl) in rcall: # const_route*: bare or qualified + if (rf, rl) in route_why and n in callable_names and (c, rf, rl, n) in rargs: # const_handed: bare or qualified if handed_to.get((c, rf, rl)): rows.append((c, 'uses', route_why[(rf, rl)], reg_cert, rf, rl)) de += [(c, m_) for m_ in handed_to[(c, rf, rl)]] # direct_edge(q,c,m) :- const_route_edge diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json index 0fd3ee3d2..e4c85a01f 100644 --- a/tests/cases/javascript/wrapped-handler-route/case.json +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -62,7 +62,7 @@ "avoid": ["src/routes/lib.js:5 — reads it", "[registered]"]}, {"why": "CONTROL: a line that also hands over a plain function (`.post(libList, plainAdmins)`) is not evidence for the const: the hand-off is plainAdmins', so the const's row stays [by name]", "run": ["impact", "src/controllers/libController.js:2"], - "want": ["[by name] lib. src/routes/lib.js:6 — registered as a POST route \"/lib-mixed\""], + "want": ["registered as a POST route \"/lib-mixed\""], "avoid": ["[registered] lib."]}, {"why": "CONTROL: a const holding a module (`const adminController = require(…)`) read on a route line is read there, not registered", "run": ["impact", "src/routes/chained.js:2"], @@ -70,5 +70,44 @@ {"why": "CONTROL: a qualified read of a const holding a value, off any route line, stays a read", "run": ["impact", "adminCount"], "want": ["src/routes/qualified.js:6 — writes/reads it"], - "avoid": ["registered as a"]} + "avoid": ["registered as a"]}, + {"why": "a const passed to a validation middleware on a route line (`validate(bodySchema)`) is an argument of that call, not a handler the router calls; and a schema built by a library (`Joi.object({…})`) is not a function even where it sits in a handler position (`.post('/schema-direct', bodySchema, h)`)", + "run": ["impact", "bodySchema"], + "want": ["[in scope] validated. src/routes/validated.js:8 — reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/opts', routeOptions, h)`) is not callable: read, not registered", + "run": ["impact", "routeOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a path prefix held in a const (`router.use(PREFIX, h)`) is a string, not a handler", + "run": ["impact", "PREFIX"], + "avoid": ["registered as a"]}, + {"why": "a router mounted on another (`apiRouter.use('/admin', adminRoutes)`, `app.use('/api', apiRouter)`) is a mount, and the receiver of a route call is not its handler: `express.Router()` returns no function the graph knows of", + "run": ["impact", "apiRouter"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the app itself, the receiver of every `app.use(…)`, is read there, not registered", + "run": ["impact", "src/app.js:3"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a const passed into a nested call on a route line (`express.static(publicDir)`) is that call's argument", + "run": ["impact", "publicDir"], + "avoid": ["registered as a"]}, + {"why": "a router module's own router const, the receiver of each route line (`router.get('/admins', listAdmins)`), is read on those lines, not registered on them, here or in the other files that declare a `router` of their own", + "run": ["impact", "src/routes/admin.js:4"], + "want": ["[in scope] admin. src/routes/admin.js:5 — reads it"], + "avoid": ["registered as a"]}, + {"why": "a wrapped handler handed over inside an array of handlers (`.get('/array', [validate(s), ctrl.listAdmins])`) is registered: a router flattens the array", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["registered as a GET route \"/array\""]}, + {"why": "a const that aliases a declared function (`const aliasOf = ctrl.plainAdmins`) holds that function, and the route line hands it over", + "run": ["impact", "aliasOf"], + "want": ["[registered] validated. src/routes/validated.js:17 — registered as a GET route \"/aliased\""]}, + {"why": "a declaration whose wrapper call starts on the next line (`const multiLine =` then `catchAsync(…)`) holds the wrapped function all the same", + "run": ["impact", "multiLine"], + "want": ["[registered] validated. src/routes/validated.js:18 — registered as a GET route \"/multi-line\""]}, + {"why": "nested wrappers (`catchAsync(validate(async (req, res) => …))`) still hold a function", + "run": ["impact", "nested"], + "want": ["registered as a GET route \"/nested\""], + "avoid": ["reads it"]} ]} diff --git a/tests/cases/javascript/wrapped-handler-route/src/app.js b/tests/cases/javascript/wrapped-handler-route/src/app.js new file mode 100644 index 000000000..0a387d7d9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/app.js @@ -0,0 +1,9 @@ +const express = require('express'); +const adminRoutes = require('./routes/admin'); +const app = express(); +const apiRouter = express.Router(); +const publicDir = __dirname + '/public'; +apiRouter.use('/admin', adminRoutes); +app.use('/api', apiRouter); +app.use('/static', express.static(publicDir)); +module.exports = app; diff --git a/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js new file mode 100644 index 000000000..1515f492b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js @@ -0,0 +1,2 @@ +const validate = (schema) => (req, res, next) => (schema ? next() : next()); +module.exports = validate; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js new file mode 100644 index 000000000..38e1daf95 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js @@ -0,0 +1,22 @@ +const express = require('express'); +const Joi = require('joi'); +const validate = require('../middlewares/validate'); +const catchAsync = require('../utils/catchAsync'); +const adminController = require('../controllers/adminController'); +const router = express.Router(); +const bodySchema = Joi.object({ name: Joi.string().custom((v) => v) }); +const routeOptions = { schema: { body: bodySchema } }; +const PREFIX = '/v'; +const aliasOf = adminController.plainAdmins; +const multiLine = + catchAsync(async (req, res) => { res.json([]); }); +const nested = catchAsync(validate(async (req, res) => { res.json([]); })); +router.post('/validated', validate(bodySchema), adminController.listAdmins); +router.post('/opts', routeOptions, adminController.plainAdmins); +router.post('/schema-direct', bodySchema, adminController.plainAdmins); +router.get('/aliased', aliasOf); +router.get('/multi-line', multiLine); +router.get('/nested', nested); +router.get('/array', [validate(bodySchema), adminController.listAdmins]); +router.use(PREFIX, adminController.plainAdmins); +module.exports = router; diff --git a/tests/cases/typescript/wrapped-handler-route/case.json b/tests/cases/typescript/wrapped-handler-route/case.json new file mode 100644 index 000000000..73fff518d --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/case.json @@ -0,0 +1,33 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a wrapped handler const (`const listUsers = catchAsync(async (req, res) => …)`) handed to a route is registered there, in TypeScript as in JavaScript. The TypeScript graph records no returned-function fact for the wrapper, so the row keeps the name's rung, as for a library wrapper; it was '[by name] … reads it'", + "run": ["impact", "listUsers"], + "want": ["[by name] users. src/routes/users.ts:4 — registered as a GET route \"/users\"", "registered as a POST route \"/users\""], + "avoid": ["src/routes/users.ts:4 — reads it"]}, + {"why": "CONTROL: a function handler passed directly keeps its route row from the method rule", + "run": ["impact", "getUser"], + "want": ["registered as a GET route \"/users/:id\""]}, + {"why": "a schema built by a library, written inside a route's options object (`{ schema: { response: { 200: TaskSchema } } }`), is part of the options, not a handler the router calls: read, never registered", + "run": ["impact", "TaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the same for a schema on an options object split over several lines", + "run": ["impact", "UpdateTaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/users', userOptions, listUsers)`) is not callable", + "run": ["impact", "userOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the router a module registers routes on is their receiver, not a handler of them", + "run": ["impact", "src/routes/users.ts:3"], + "avoid": ["registered as a"]}, + {"why": "a router mounted with `app.use('/api', api)` is a mount, and the app is the receiver: neither is registered as a route", + "run": ["impact", "src/app.ts:4"], + "want": ["[by name] server. src/server.ts:3 — reads it"], + "avoid": ["registered as a"]}, + {"why": "…nor the app", + "run": ["impact", "src/app.ts:3"], + "want": ["reads it"], + "avoid": ["registered as a"]} + ]} diff --git a/tests/cases/typescript/wrapped-handler-route/src/app.ts b/tests/cases/typescript/wrapped-handler-route/src/app.ts new file mode 100644 index 000000000..78f0e3830 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/app.ts @@ -0,0 +1,4 @@ +import express from 'express'; +import usersRouter from './routes/users'; +export const app = express(); +export const api = express.Router(); diff --git a/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts new file mode 100644 index 000000000..216a01342 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express'; +import { catchAsync } from '../utils/catchAsync'; +export const listUsers = catchAsync(async (req: Request, res: Response) => { res.json([]); }); +export async function getUser(req: Request, res: Response) { res.json({}); } +export const userOptions = { strict: true }; diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts new file mode 100644 index 000000000..8f4274dfc --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { TaskSchema, CreateTaskSchema, UpdateTaskSchema } from '../schemas/task'; +export default async function plugin(fastify: FastifyInstance) { + fastify.get('/:id', { schema: { response: { 200: TaskSchema } } }, async function getTask(request: any) { + return { id: request.params.id }; + }); + fastify.post('/', { schema: { body: CreateTaskSchema, response: { 201: TaskSchema } } }, async (request: any) => request.body); + fastify.patch( + '/:id', + { + schema: { + body: UpdateTaskSchema, + response: { 200: TaskSchema } + } + }, + async function updateTask(request: any) { + return request.body; + } + ); +} diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts new file mode 100644 index 000000000..785505488 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts @@ -0,0 +1,7 @@ +import express from 'express'; +import { listUsers, getUser, userOptions } from '../controllers/userController'; +const router = express.Router(); +router.get('/users', listUsers); +router.get('/users/:id', getUser); +router.post('/users', userOptions, listUsers); +export default router; diff --git a/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts new file mode 100644 index 000000000..0696333f5 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts @@ -0,0 +1,4 @@ +import { Type } from '@sinclair/typebox'; +export const TaskSchema = Type.Object({ id: Type.String(), name: Type.String() }); +export const CreateTaskSchema = Type.Object({ name: Type.String() }); +export const UpdateTaskSchema = Type.Partial(CreateTaskSchema); diff --git a/tests/cases/typescript/wrapped-handler-route/src/server.ts b/tests/cases/typescript/wrapped-handler-route/src/server.ts new file mode 100644 index 000000000..1af292190 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/server.ts @@ -0,0 +1,4 @@ +import usersRouter from './routes/users'; +import { app, api } from './app'; +api.use('/v1', usersRouter); +app.use('/api', api); diff --git a/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts new file mode 100644 index 000000000..027203028 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts @@ -0,0 +1,2 @@ +export type Handler = (req: any, res: any, next: any) => Promise | void; +export const catchAsync = (fn: Handler): Handler => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);