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) } +}