From 4e9d3071327dd61ab816fc66117e1e7e4cf71062 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:56:39 -0700 Subject: [PATCH] javascript: a call through a field holding a platform-made wrapper of a project function reaches that function A field or module variable set to what a platform call returned when handed a project function (promisify(store.find.bind(store))) was an ambient terminal, and path called the caller independent of the very method the wrapper runs. The wrapper call now reaches the bound method when the graph knows it (one of a set), and is an open value callee when it does not. path also counts a call that ends on a function type's signature (TypeScript) as a value callee, and lists a value callee named like the other endpoint first. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 31 ++++++++ graph/javascript/souffle/decls_all.dl | 5 ++ .../skills/axiomcode/scripts/axiomcode-path | 30 ++++++-- .../stored-field-callee-shapes/case.json | 75 +++++++++++++++++++ .../stored-field-callee-shapes/src/shapes.js | 11 ++- .../function-stored-in-a-holder/case.json | 17 +++++ .../function-stored-in-a-holder/src/app.ts | 14 ++++ 7 files changed, 176 insertions(+), 7 deletions(-) diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index 34fbd6b81..a257ba732 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -107,6 +107,37 @@ module_variable_from_call(v) :- var_init("client", _, e, v), expr_kind(_, k, _, !variable_reassigned(v), !call_passes_function(e). call_passes_function(e) :- call_arg(e, _, a), expr_value(a, "func", _). call_passes_function(e) :- call_arg(e, _, a), expr_introduces(_, _, a). +// `promisify(store.find.bind(store))`: a `.bind` always evaluates to a function, typed +// receiver or not. A bound platform function (`Math.max.bind(Math)`) is the platform's. +call_passes_function(e) :- call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_child(_, a, "CALLEE", _, f), !bound_platform_function(f). +bound_platform_function(f) :- expr_kind(_, "PROPERTY_ACCESS", _, f), expr_child(_, f, "ACCESS_TARGET", _, r), + expr_value(r, "ambient", _). +// A holder whose value is what a PLATFORM call returned when handed a project function +// (`this.find = promisify(s.find.bind(s))`, `const f = util.callbackify(g)`): the value +// is a wrapper the platform made around that function, and calling it runs the +// function. The platform value on the holder made the call an ambient terminal, a +// claimed correct end, and path said "independent" of the very method the wrapper +// runs. A platform value made from no project function (`promisify(setTimeout)`) +// keeps its platform reading. +// When the graph knows the function the wrapper was made from (a function value, or +// the method a `.bind` site resolves to), the call runs it: one of a set, beside the +// platform row the call keeps. Only when it knows none is the callee an open value. +unresolved_value_callee(ce, "field") :- wrapper_holder_call(ce, val), !wrapper_call_target(ce, _), made_from_function(val), + field_call(ce, _, _, _). +unresolved_value_callee(ce, "module_variable") :- wrapper_holder_call(ce, e), !wrapper_call_target(ce, _), made_from_function(e), + !field_call(ce, _, _, _). +wrapper_holder_call(ce, val) :- field_call(ce, k, t, n), !call_resolved(ce), field_assignment(k, t, n, val). +wrapper_holder_call(ce, e) :- value_callee_unresolved(ce, c), expr_binding(_, v, c), + var_decl("client", _, _, _, _, _, v), !var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c), + var_init("client", _, e, v), !variable_reassigned(v). +made_from_function(e) :- expr_kind(_, "CALL", _, e), expr_value(e, "ambient", _), call_passes_function(e). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), expr_value(a, "func", m). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_resolves_to_method(a, m). +wrapper_call_target(ce, m) :- wrapper_holder_call(ce, e), wrapper_runs(e, m), call_target_count(ce, 0). +call_chain_edge(ce, caller, "-", m, "client", "multi_inferred", kind) :- + wrapper_call_target(ce, m), !call_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). variable_reassigned(v) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt). // `(c ? a : b)()`, `(0, cb)()`, `make()()`: the callee is computed by an expression. diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17b..e0f899c5c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -103,6 +103,11 @@ .decl module_variable_from_call(c0:symbol) .decl variable_reassigned(c0:symbol) .decl call_passes_function(c0:symbol) +.decl bound_platform_function(c0:symbol) +.decl made_from_function(c0:symbol) +.decl wrapper_holder_call(c0:symbol, c1:symbol) +.decl wrapper_runs(c0:symbol, c1:symbol) +.decl wrapper_call_target(c0:symbol, c1:symbol) .decl live_export_variable(c0:symbol, c1:symbol) .decl this_type_open(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f1..84efa1c94 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -71,6 +71,8 @@ def chain_json(g, chain): # declarations that describe a callable and have no body (score.py's bodiless_kinds) BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE', 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} +# of those, the ones a call through a VALUE lands on: a function type or a bare call signature, not a member of an interface +FUNCTION_TYPE_KINDS = {'FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE'} # the one name a front end gives every lambda it declares (Python and C#; Java declares none): a name that says nothing # about WHICH lambda, so it is never a target on its own (G.lambda_label, G.lambda_target) LAMBDA_NAMES = {''} @@ -1767,8 +1769,18 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # `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. + # A typed front end RESOLVES the same call, to the holder's function type (`find: (e: string) => …`), a signature + # with no body: the chain ends there, and the function the holder is given is what runs. Those count too. opaque = [] - if not (remote_found or lib_side or sends) and g.has('ext_unresolved_value_callee'): + value_sql = [] + if g.has('ext_unresolved_value_callee'): + value_sql.append("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" + " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)") + if g.has('methods') and g.has('call_edges'): + value_sql.append("SELECT DISTINCT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, 'function_type' why FROM call_edges e" + " JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id" + f" WHERE m.kind IN ({','.join(repr(k) for k in sorted(FUNCTION_TYPE_KINDS))}) AND s.caller_id IN (%s)") + if not (remote_found or lib_side or sends) and value_sql: 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: @@ -1778,8 +1790,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): ids = sorted(seen); rows = [] for i in range(0, len(ids), 900): part = ids[i:i + 900] - rows += g.q("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" - " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)" % ','.join('?' * len(part)), *part) + for sql in value_sql: rows += g.q(sql % ','.join('?' * len(part)), *part) if rows: opaque.append((lx, sorted(rows, key=lambda r: (g.site_file(r['f']), r['ln'] or 0)))) # …but a framework may still connect them, and the graph holds the evidence for it: the target is registered # under a key, and the source writes that key. That is not a chain of calls, so it is reported and not walked. @@ -1821,15 +1832,22 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): 'instance_member': 'a function stored on the instance from outside the class', 'parameter_member': 'a member of a parameter', 'expression': 'computed by an expression', 'getattr': 'an attribute looked up by a name computed at run time', - 'imported_variable': "another module's exported variable, which that module reassigns"} + 'imported_variable': "another module's exported variable, which that module reassigns", + 'function_type': 'held by a field, variable or parameter of a function type, whatever function it is given'} + # A value callee that carries the OTHER endpoint's name (`this.find()` where the field holds a wrapper + # around find) is the likeliest connection: listed first, and said so. + names = {la: {g.sym[i]['name'] for i in B_ if i in g.sym}, lb: {g.sym[i]['name'] for i in A_ if i in g.sym}} for lx, rows in opaque: + other = lb if lx == la else la + rows = sorted(rows, key=lambda r: r['n'] not in names[lx]) # the sites that make the answer unknown, in --json too: a consumer reading only `answers` saw "no chain" RESULT['value_calls'] = RESULT.get('value_calls', []) + [ {'side': lx, 'caller': g.disp(r['c']), 'callee': r['n'] or '', 'at': f"{g.site_file(r['f'])}:{r['ln']}", - 'callee_is': WHY.get(r['why'], r['why'])} for r in rows[:50]] + 'callee_is': WHY.get(r['why'], r['why']), 'named_like_target': r['n'] in names[lx]} for r in rows[:50]] print(f" {lx} reaches {len(rows)} call(s) through a value:") for r in rows[:4]: - print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}") + print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}" + + (f" — named like the target: if it holds {other}, the chain is real" if r['n'] in names[lx] else '')) if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) return 1 diff --git a/tests/cases/javascript/stored-field-callee-shapes/case.json b/tests/cases/javascript/stored-field-callee-shapes/case.json index b17c9107f..12284de97 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/case.json +++ b/tests/cases/javascript/stored-field-callee-shapes/case.json @@ -215,6 +215,81 @@ "avoid": [ "connection is UNKNOWN, not absent" ] + }, + { + "why": "Svc.login calls this.find(), a field holding what a platform call (promisify) returned for a bound project method: the wrapper runs that method, so path says unknown and names the same-named site, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`find()` in Svc.login at src/shapes.js:42 — the callee is a function stored in a field", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the same wrapper held in a module variable, bound to a method the graph resolves: the wrapper runs it, so the chain is real", + "run": [ + "path", + "viaModule", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:44] Store.find" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "a field wrapper whose bound receiver is typed by the argument its constructor is given resolves the same way", + "run": [ + "path", + "Auth.login", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:45] Store.find" + ], + "avoid": [] + }, + { + "why": "control: a field holding what a platform call returned for a platform function (promisify(setTimeout)) is a platform call", + "run": [ + "path", + "Clock.tick", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a platform call handed a bound PLATFORM function (promisify(Math.max.bind(Math))) is a platform call", + "run": [ + "path", + "Reader.go", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] } ] } diff --git a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js index 753496438..9dfe4f1ac 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js +++ b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js @@ -37,6 +37,15 @@ const tag = require('util').format; function tags() { return tag('%s', 'x'); } const wrapped = debug(alpha); function callsWrapped() { return wrapped(); } +const { promisify } = require('util'); +class Store { find(e) { return e; } } +class Svc { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +const findAsync = promisify(new Store().find.bind(new Store())); +function viaModule(e) { return findAsync(e); } +class Auth { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +function makeAuth() { return new Auth(new Store()); } +class Clock { constructor() { this.wait = promisify(setTimeout); } tick() { return this.wait(1); } } +class Reader { constructor() { this.read = promisify(Math.max.bind(Math)); } go() { return this.read(1); } } // controls: none of these is a value callee const EventEmitter = require('events'); class Bus extends EventEmitter { go() { return this.emit('x'); } } @@ -44,4 +53,4 @@ class Own { own() { return 1; } run() { return this.own(); } } class Known { constructor() { this.fn = alpha; } run() { return this.fn(); } } class Fixed { constructor(cb) { this.cb = alpha || cb; } } function useOwn() { const o = new Own(); return o.run(); } -module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn }; +module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn, Store, Svc, viaModule, Auth, makeAuth, Clock, Reader }; diff --git a/tests/cases/typescript/function-stored-in-a-holder/case.json b/tests/cases/typescript/function-stored-in-a-holder/case.json index bb0e78733..8bb4b2eea 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/case.json +++ b/tests/cases/typescript/function-stored-in-a-holder/case.json @@ -114,6 +114,23 @@ "src/app.ts: " ], "avoid": [] + }, + { + "why": "a call through a function-typed field the graph cannot fill (a library-made wrapper) ends on the type's signature: path says unknown, names the site and that it carries the target's name, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "— the callee is held by a field, variable or parameter of a function type", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] } ] } diff --git a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts index f7a41a80c..29c9a61d7 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts +++ b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts @@ -54,3 +54,17 @@ export function price(registry: Registry, n: number): string { export function checkout(registry: Registry): string { return price(registry, 3) } + +import { promisify } from 'util' + +export class Store { + find(e: string): string { return e } +} + +// A field of a function type holding what a library returned for a bound method: the call resolves to the +// field's signature, which has no body; the wrapped method is what runs. +export class Svc { + private find: (e: string) => Promise + constructor(s: Store) { this.find = promisify(s.find.bind(s)) as any } + login(e: string) { return this.find(e) } +}