diff --git a/graph/javascript/engine/call-edge-generation/callbacks.dl b/graph/javascript/engine/call-edge-generation/callbacks.dl index 107842dbd..6dbc84000 100644 --- a/graph/javascript/engine/call-edge-generation/callbacks.dl +++ b/graph/javascript/engine/call-edge-generation/callbacks.dl @@ -66,6 +66,34 @@ options_value_kind("obj"). call_has_client_target(ce) :- expr_resolves_to_method(ce, m), method_prov(m, "client"). call_has_client_target(ce) :- new_constructs(ce, t), type_decl("client", _, _, _, _, t). +// A function WRAPPED BY A PACKAGE CALL and kept in a binding: `export const plugin = fp(async (app) => …)`, +// `const ext = Prisma.defineExtension((client) => …)`, then `app.register(plugin)`, `client.$extends(ext)`. +// The package returns the function it was handed, or one that runs it, and nothing in the project says +// which, so the binding had no value and the registration reached nothing, although the same literal +// handed to it bare is registered. The wrapping site is a value of its own, ("libwrap", site), carried +// wherever a value goes (a const, an import, an export), and a registration handed it registers what the +// site was handed. The site is recognised syntactically, by a callee rooted at a binding imported from a +// package, so the value stays below the resolver's negations; a project wrapper is followed through its +// body instead (value-flow.dl, "wrap"). +lib_rooted(e) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, _, _, _, _, _, out, _, imp), import_outcome_is_package(out). +lib_rooted(e) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_child(_, e, "ACCESS_TARGET", _, r), lib_rooted(r). +import_outcome_is_package("RESOLVED_EXTERNAL"). +import_outcome_is_package("UNRESOLVED_MISSING"). +access_kind_reads_member("PROPERTY_ACCESS"). +access_kind_reads_member("OPTIONAL_ACCESS"). +lib_wrap_site(s) :- call_site(_, ck, _, _, _, _, s, _, _), call_kind_is_callee_form(ck), + expr_child(_, s, "CALLEE", _, c), lib_rooted(c). +lib_wrap_site(s) :- call_site(_, ck, _, "SYNTACTIC", _, _, s, _, _), call_kind_is_member_form(ck), + expr_child(_, s, "RECEIVER", _, r), lib_rooted(r). +// Handed over by NAME: `register(wrap(f))` needs nothing new, since the inner site already registers f +// from the same caller. The value is not a callee (callee-resolution.dl): what a call of it runs is still +// unknown, and says so. +expr_value(s, "libwrap", s) :- lib_wrap_site(s), call_arg(s, _, a), expr_value(a, "func", _). +callback_registered(ce, m) :- invocation_site(ce, _), !call_has_client_target(ce), !reflective_site(ce), + call_arg(ce, _, arg), !expr_kind(_, "CALL", _, arg), + expr_value(arg, "libwrap", s), call_arg(s, _, a), expr_value(a, "func", m). + // A listener runs with the EMITTER as `this` (`e.on('x', function () { this.other(); })`). this_value(m, k, i) :- event_handler(k, i, _, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), k != "module". 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/engine/expression-resolution/callee-resolution.dl b/graph/javascript/engine/expression-resolution/callee-resolution.dl index c41600158..946d7cb34 100644 --- a/graph/javascript/engine/expression-resolution/callee-resolution.dl +++ b/graph/javascript/engine/expression-resolution/callee-resolution.dl @@ -27,7 +27,7 @@ // ── callee_value(CallExpr, K, I) — the value being invoked ───────────────── callee_value(ce, k, i) :- call_site(_, ck, _, _, _, _, ce, _, _), call_kind_is_callee_form(ck), - expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i). + expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i), k != "libwrap". # what a package wrapper returns runs nothing known (callbacks.dl) // `f.call(o)`: f runs. But the parser classifies by NAME, so `selector.apply(node)` // on an object with its own `apply` method is here too — and for that reading the // CALLEE child (the object) is the receiver and its member is the target. Both diff --git a/graph/javascript/engine/resolution/ambient.dl b/graph/javascript/engine/resolution/ambient.dl index 3f0d8ebe2..7d321c896 100644 --- a/graph/javascript/engine/resolution/ambient.dl +++ b/graph/javascript/engine/resolution/ambient.dl @@ -73,6 +73,26 @@ modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_ expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "Object", recv). modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "_extend", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "util", recv). +// `promisify(f)` is f behind a transparent wrapper (value-flow.dl): its value is what f holds. +modelled_platform_call(ce) :- promisify_call(ce). +// promisify_call(Call): the core `util` module's `promisify`, as a name imported from it +// (`const { promisify } = require('util')`, `import { promisify } from 'node:util'`) or as +// a member of it (`util.promisify(f)` with `util` bound to the module, `require('util').promisify(f)`). +// Recognised on the import rows and the binder, never by the name alone: a project's own +// `promisify` is an ordinary function and resolves as one. +promisify_call(ce) :- call_site(_, "FUNCTION_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_binding(_, v, c), var_import(_, imp, v), + import_decl(_, spec, _, _, "promisify", _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_binding(_, v, r), var_import(_, imp, v), + import_decl(_, spec, _, bf, _, _, "RESOLVED_BUILTIN", _, imp), import_binds_whole_module(bf), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "MODULE_EDGE_CALL", _, r), expr_module_edge(_, imp, r), + import_decl(_, spec, _, _, _, _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +import_binds_whole_module("DEFAULT"). +import_binds_whole_module("NAMESPACE"). +util_module("util"). +util_module("node:util"). // The Object statics whose result frameworks.dl gives a value of its own. modelled_object_method("assign"). modelled_object_method("create"). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 15379c699..d6f8c1323 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -155,6 +155,11 @@ expr_value(e, k, i) :- expr_kind(_, "NEW", _, e), new_callee_value(e, "func", m) // (callee-resolution.dl); the VALUE of the expression is f as well. expr_value(e, "func", m) :- expr_kind(_, "CALL", _, e), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, e, _, _), expr_child(_, e, "CALLEE", _, c), expr_value(c, "func", m). +// `promisify(f)` evaluates to a function that runs f (with a callback appended), so a call +// through it reaches f: the wrapper is transparent. What the argument holds is what the +// result holds, so `promisify(fs.readFile)` stays the platform's and an unknown argument +// stays unknown. ambient.dl keeps the platform's own value off it (modelled_platform_call). +expr_value(ce, k, i) :- promisify_call(ce), call_arg(ce, 0, a), expr_value(a, k, i). // Transparent wrappers: `await x`, `(c ? a : b)`, `a || b`, `a && b`, `a ?? b`, // `x = v` (an assignment expression evaluates to v), `(a, b)`. expr_value(e, k, i) :- expr_kind(_, "AWAIT", _, e), expr_child(_, e, _, _, c), expr_value(c, k, i). @@ -330,6 +335,18 @@ this_value(m, k, i) :- method_this_binding(_, "LEXICAL", m), method_enclosing(m, this_value(m, "obj", l) :- expr_child(_, l, "PROPERTY_VALUE", _, v), expr_kind(_, "OBJECT_LITERAL", _, l), expr_introduces(_, m, v), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). this_value(m, "obj", l) :- literal_owns_method(l, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). +// `f.bind(o)` fixes f's `this` to o for every call through what it returns, wherever that +// value travels: `onEvent.bind({ repo })` then `this.repo.append()` inside onEvent. The +// thisArg is the site's RECEIVER child. Only objects and instances, and only a function no +// class owns (a member's `this` is its instance already): `bind(null)` and a primitive +// bind nothing, and a class member re-bound to its own instance adds nothing new. +// `f.call(o)` / `f.apply(o)` are not read here: they run f once at that site. +this_value(m, k, i) :- call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_value(c, "func", m), + method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), + expr_child(_, ce, "RECEIVER", _, o), expr_value(o, k, i), bound_this_kind(k). +bound_this_kind("obj"). +bound_this_kind("inst"). // `T.prototype.constructor = T` is a BACK-REFERENCE, not an installation: nothing // calls it with the prototype as `this`, and reading it as one gave a constructor // the `Object.create(Base.prototype)` object as `this` (#708). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17b..7c23b17ce 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) @@ -300,6 +305,10 @@ .decl well_known_symbol_read(c0:symbol) .decl modelled_platform_call(c0:symbol) .decl modelled_object_method(c0:symbol) +.decl promisify_call(c0:symbol) +.decl util_module(c0:symbol) +.decl import_binds_whole_module(c0:symbol) +.decl bound_this_kind(c0:symbol) .decl free_namespace(c0:symbol, c1:symbol) .decl ts_export_star(c0:symbol, c1:symbol) .decl ts_export_star_helper(c0:symbol) @@ -451,6 +460,10 @@ .decl options_object_member(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl options_value_kind(c0:symbol) .decl call_has_client_target(c0:symbol) +.decl lib_rooted(c0:symbol) +.decl import_outcome_is_package(c0:symbol) +.decl access_kind_reads_member(c0:symbol) +.decl lib_wrap_site(c0:symbol) .decl callback_registered(c0:symbol, c1:symbol) .decl event_handler(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl event_register_method(c0:symbol) diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 5e96f8689..230c1f1bf 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -329,14 +329,13 @@ partial_target(site, m) :- call_arg(site, "0", a), expr_denotes_method("client", a, m). -// ── a name holding a partial denotes the partial's target ──────────────────── -binding_value_method(p, b, m) :- - binding_rebinding(p, "1", _, _, b), - expr_binding(p, b, "STORE", tgt), - assign_pair(p, tgt, val), - call_of_expr(val, site), - partial_target(site, m), - p = "client". +// ── a partial denotes the partial's target ─────────────────────────────────── +// The call EXPRESSION, so wherever the partial goes the target goes with it: a name +// (`add_ten = partial(add, 10)`, through (d)'s alias rule), an attribute +// (`self.op = partial(repo.append)`, through field_holds_method), an argument. +expr_denotes_method("client", e, m) :- + call_of_expr(e, site), + partial_target(site, m). // ── param_arg_class(ParamHash, TypeHash) — a CLASS OBJECT reaching a parameter ─ // Distinct from param_arg_type on purpose: param_arg_type means "an INSTANCE of this diff --git a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js index 0a29fdf73..baabbc0fc 100644 --- a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js +++ b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js @@ -14,3 +14,9 @@ function viaCtor() { return new Transform({ transform }); } function viaProject() { return localWalk([1], { filter: keep }); } function main() { direct(); viaVar(); viaPlatform(); viaCtor(); viaProject(); } main(); +// a function wrapped by a package call and kept in a const, then handed to a package registration: registered +function migrate() { return 1; } +const plugin = walk(async () => migrate()); +const settings = walk(42); +function viaWrapped() { walk.register(plugin); walk.register(settings); } +module.exports = { viaWrapped }; diff --git a/graph/test/javascript/cases/73-bound-function-identity/src/bound.js b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js new file mode 100644 index 000000000..40374a112 --- /dev/null +++ b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js @@ -0,0 +1,50 @@ +'use strict'; +const { promisify } = require('util'); + +class Repo { + append(x) { return x; } + find(k) { return k; } +} +class Other { + append(x) { return x; } +} + +// (a) a field or variable assigned from x.m.bind(x) is x.m +class Service { + constructor(r) { + this.repo = r; + this.add = this.repo.append.bind(this.repo); + this.lookup = promisify(r.find.bind(r)); + } + go() { return this.add(1); } + get(k) { return this.lookup(k); } +} +function makeService() { return new Service(new Repo()); } +const repo = new Repo(); +const bound = repo.append.bind(repo); +function viaVariable() { return bound(2); } +const wrapped = promisify(repo.find.bind(repo)); +function viaPromisified() { return wrapped('k'); } +const util = require('util'); +const viaNamespace = util.promisify(repo.find.bind(repo)); +function viaNamespaceCall() { return viaNamespace('n'); } + +// (b) a function bound to an object literal sees its keys through `this` +function onEvent(e) { return this.repo.append(e); } +const handler = onEvent.bind({ repo: new Repo() }); +function fire() { return handler(1); } +const handlers = { + created: function onCreated(e) { return this.store.append(e); }, +}; +const onCreatedBound = handlers.created.bind({ store: new Other() }); + +// controls: none of these changes +function unbound(e) { return this.repo.append(e); } +function callsUnbound() { return unbound.call({ repo: new Repo() }, 1); } +const snapshot = repo.find.bind(null); +function openBind(fn) { const g = fn.bind(repo); return g(); } +const own = { promisify(f) { return () => f; } }; +const notUtil = own.promisify(repo.find); +function viaOwnPromisify() { return notUtil(); } + +module.exports = { makeService, viaVariable, viaPromisified, fire, onCreatedBound, callsUnbound, snapshot, openBind, viaNamespaceCall, viaOwnPromisify }; diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index f261bb398..2937e0b0a 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -8,6 +8,10 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:19:16 FUNCTION_CALL walk callee_untyped +unresolved main.js:20:18 FUNCTION_CALL walk callee_untyped +unresolved main.js:21:25 METHOD_CALL register receiver_untyped +unresolved main.js:21:48 METHOD_CALL register receiver_untyped unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.edges b/graph/test/javascript/expected/34-options-object-callbacks.edges index 04390e7a1..2336e81fe 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag index 99d2f7ba0..72bf6bbbc 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -6,6 +6,8 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:21:25 METHOD_CALL register member_absent +unresolved main.js:21:48 METHOD_CALL register member_absent unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges index f5582b137..10163a112 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.oracle b/graph/test/javascript/expected/34-options-object-callbacks.oracle index be68d1524..f31f7ea51 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.oracle +++ b/graph/test/javascript/expected/34-options-object-callbacks.oracle @@ -5,4 +5,5 @@ main.js:15:39 FUNCTION_CALL viaPlatform EXACT main.js:12:1 main.js:15:54 FUNCTION_CALL viaCtor EXACT main.js:13:1 main.js:15:65 FUNCTION_CALL viaProject EXACT main.js:14:1 main.js:16:1 FUNCTION_CALL main EXACT main.js:15:1 +main.js:19:33 FUNCTION_CALL migrate EXACT main.js:18:1 # defects: 0 diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.edges b/graph/test/javascript/expected/70-single-file-component-scripts.edges index 0bb4c4cbb..21bf8d411 100644 --- a/graph/test/javascript/expected/70-single-file-component-scripts.edges +++ b/graph/test/javascript/expected/70-single-file-component-scripts.edges @@ -7,6 +7,7 @@ components/List.svelte:5:39 FUNCTION_CALL fetchProducts -> known_edge lib/for components/List.svelte:6:29 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice components/Price.vue:1:25 FUNCTION_CALL onClick -> known_edge components/Price.vue:8:1 onClick components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> ambiguous_unknown - +components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> callback_registered components/Price.vue:7:24 components/Price.vue:6:15 FUNCTION_CALL defineProps -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> callback_registered components/Price.vue:7:24 diff --git a/graph/test/javascript/expected/73-bound-function-identity.diag b/graph/test/javascript/expected/73-bound-function-identity.diag new file mode 100644 index 000000000..fcdd22f91 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.diag @@ -0,0 +1,11 @@ +import_cause bound.js:28:14 util builtin +import_cause bound.js:2:9 util builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved bound.js:17:19 FUNCTION_CALL promisify no_target +unresolved bound.js:26:17 FUNCTION_CALL promisify no_target +unresolved bound.js:29:22 METHOD_CALL promisify no_target +unresolved bound.js:42:30 METHOD_CALL append receiver_untyped +unresolved bound.js:45:35 FUNCTION_CALL_BIND fn callee_untyped +unresolved bound.js:45:57 FUNCTION_CALL g callee_untyped +value_callee bound.js:45:35 fn.bind parameter +value_callee bound.js:45:57 g local diff --git a/graph/test/javascript/expected/73-bound-function-identity.edges b/graph/test/javascript/expected/73-bound-function-identity.edges new file mode 100644 index 000000000..6cab99516 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.edges @@ -0,0 +1,35 @@ +bound.js:16:16 FUNCTION_CALL_BIND this.repo.append.bind -> known_edge bound.js:5:3 append +bound.js:17:19 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:17:19 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:17:29 FUNCTION_CALL_BIND r.find.bind -> known_edge bound.js:6:3 find +bound.js:19:17 METHOD_CALL this.add -> known_edge bound.js:5:3 append +bound.js:20:19 METHOD_CALL this.lookup -> known_edge bound.js:6:3 find +bound.js:22:33 CONSTRUCTOR_CALL Service -> known_edge bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:23:14 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:24:15 FUNCTION_CALL_BIND repo.append.bind -> known_edge bound.js:5:3 append +bound.js:25:33 FUNCTION_CALL bound -> known_edge bound.js:5:3 append +bound.js:26:17 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:26:17 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:26:27 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:27:36 FUNCTION_CALL wrapped -> known_edge bound.js:6:3 find +bound.js:29:22 METHOD_CALL util.promisify -> ambient_terminal - +bound.js:29:22 METHOD_CALL util.promisify -> callback_registered bound.js:6:3 find +bound.js:29:37 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:30:38 FUNCTION_CALL viaNamespace -> known_edge bound.js:6:3 find +bound.js:33:30 METHOD_CALL this.repo.append -> known_edge bound.js:5:3 append +bound.js:34:17 FUNCTION_CALL_BIND onEvent.bind -> known_edge bound.js:33:1 onEvent +bound.js:34:38 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:35:26 FUNCTION_CALL handler -> known_edge bound.js:33:1 onEvent +bound.js:37:43 METHOD_CALL this.store.append -> known_edge bound.js:9:3 append +bound.js:39:24 FUNCTION_CALL_BIND handlers.created.bind -> known_edge bound.js:37:12 onCreated +bound.js:39:55 CONSTRUCTOR_CALL Other -> implicit_constructor - +bound.js:42:30 METHOD_CALL this.repo.append -> ambiguous_unknown - +bound.js:43:34 FUNCTION_CALL_CALL unbound.call -> known_edge bound.js:42:1 unbound +bound.js:43:55 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:44:18 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:45:35 FUNCTION_CALL_BIND fn.bind -> ambiguous_unknown - +bound.js:45:57 FUNCTION_CALL g -> ambiguous_unknown - +bound.js:47:17 METHOD_CALL own.promisify -> callback_registered bound.js:6:3 find +bound.js:47:17 METHOD_CALL own.promisify -> known_edge bound.js:46:15 promisify +bound.js:48:37 FUNCTION_CALL notUtil -> known_edge bound.js:46:37 diff --git a/graph/test/javascript/expected/73-bound-function-identity.oracle b/graph/test/javascript/expected/73-bound-function-identity.oracle new file mode 100644 index 000000000..01c9e34bd --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.oracle @@ -0,0 +1,15 @@ +bound.js:22:33 CONSTRUCTOR_CALL Service EXACT bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:23:14 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:24:15 FUNCTION_CALL_BIND bind EXACT bound.js:5:3 +bound.js:26:27 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:29:37 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:34:17 FUNCTION_CALL_BIND bind EXACT bound.js:33:1 +bound.js:34:38 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:39:24 FUNCTION_CALL_BIND bind EXACT bound.js:37:12 +bound.js:39:55 CONSTRUCTOR_CALL Other SYNTHESIZED_OK +bound.js:43:34 FUNCTION_CALL_CALL call EXACT bound.js:42:1 +bound.js:43:55 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:44:18 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:47:17 METHOD_CALL promisify EXACT bound.js:46:15 +# defects: 0 diff --git a/graph/test/python/cases/08-callables/src/main.py b/graph/test/python/cases/08-callables/src/main.py index 80ac9186e..baa116aaa 100644 --- a/graph/test/python/cases/08-callables/src/main.py +++ b/graph/test/python/cases/08-callables/src/main.py @@ -9,7 +9,8 @@ partial functools.partial exposes `.func`, so CPython can name the target lambda stored in a dict, reached by subscript: no name at the call site attribute a plain function assigned to an instance attribute, which does NOT - go through the descriptor protocol and so is not a bound method + go through the descriptor protocol and so is not a bound method; + a partial stored there calls its target the same way """ import functools @@ -31,10 +32,16 @@ class Holder: def __init__(self): # A function on an INSTANCE attribute. Not a method: no `self` is bound. self.op = add + self.bump = functools.partial(add, 1) + # control: a partial over a builtin stays the platform's + self.biggest = functools.partial(max, 0) def use(self): return self.op(1, 2) + def use_partial(self): + return self.bump(2) + self.biggest(3) + TABLE = { # A lambda reached by subscript. There is no name to resolve. @@ -52,6 +59,7 @@ def main(): print(TABLE["double"](21)) print(Holder().use()) + print(Holder().use_partial()) if __name__ == "__main__": diff --git a/graph/test/python/expected/08-callables.edges b/graph/test/python/expected/08-callables.edges index a89f21f40..5b0c619d2 100644 --- a/graph/test/python/expected/08-callables.edges +++ b/graph/test/python/expected/08-callables.edges @@ -1,7 +1,11 @@ +ambiguous_unknown SELF_CALL main.Holder.use_partial -> - +boundary_lib METHOD_CALL main.Holder.__init__ -> external:functools.partial boundary_lib METHOD_CALL main.main -> external:functools.partial boundary_lib SIMPLE_CALL main.main -> builtin:print known_edge CHAINED_CALL main.main -> main.Holder.use +known_edge CHAINED_CALL main.main -> main.Holder.use_partial known_edge SELF_CALL main.Holder.use -> main.add +known_edge SELF_CALL main.Holder.use_partial -> main.add known_edge SIMPLE_CALL main. -> main.main known_edge SIMPLE_CALL main.main -> main.Holder.__init__ known_edge SIMPLE_CALL main.main -> main.Multiplier.__call__ diff --git a/graph/test/python/expected/08-callables.tiers b/graph/test/python/expected/08-callables.tiers index 8c81e90ff..9d83aad23 100644 --- a/graph/test/python/expected/08-callables.tiers +++ b/graph/test/python/expected/08-callables.tiers @@ -1,26 +1,28 @@ -distinct call sites emitted: 13 +distinct call sites emitted: 20 --- by tier: edge ROWS, and the distinct SITES they cover --- - 5 rows 5 sites boundary_lib - 8 rows 8 sites known_edge + 1 rows 1 sites ambiguous_unknown + 8 rows 8 sites boundary_lib + 11 rows 11 sites known_edge --- edge rows by call kind --- - 1 CHAINED_CALL - 1 METHOD_CALL - 1 SELF_CALL - 9 SIMPLE_CALL + 2 CHAINED_CALL + 3 METHOD_CALL + 3 SELF_CALL + 11 SIMPLE_CALL 1 SUBSCRIPT_CALL --- unresolved reasons --- - (none — every site resolved) + 1 no_rule --- the engine's own conservation ledger --- - 13 _total_sites - 5 boundary_lib - 8 known_edge + 20 _total_sites + 1 ambiguous_unknown + 8 boundary_lib + 11 known_edge --- reconciling rows against the conserved site count --- - edge rows 13 + edge rows 20 minus extra rows from multi-target sites 0 - = tier/site pairs 13 - engine's conserved site total 13 + = tier/site pairs 20 + engine's conserved site total 20 diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts new file mode 100644 index 000000000..8392d9614 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts @@ -0,0 +1,10 @@ +import type { Pub } from "./pub"; + +// Sees Pub, shares the name `publish`, and still does not satisfy it: its publish needs +// two arguments where Pub's callers pass one. +export class Bus { + publish(name: string, payload: unknown): void {} +} + +export const bus = new Bus(); +export type Seen = Pub; diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts new file mode 100644 index 000000000..e8b111a51 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts @@ -0,0 +1,12 @@ +// Brings Pub in through a package specifier the engine cannot follow to pub.ts (a +// workspace package whose build output is absent), so the import walk never reaches Pub's +// module. The import still names Pub, and that is the evidence: Declared stays a Pub +// candidate. +// @ts-expect-error the package is not installed in this case +import type { Pub } from "@workspace/pub"; + +export class Declared implements Pub { + publish(e: { id: string }): void {} +} + +export const declared = new Declared(); diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts new file mode 100644 index 000000000..6af8cd5e5 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -0,0 +1,18 @@ +import { Relay, type Pub } from "./pub"; + +// CONTROL: no `implements`, an extra optional parameter, and passed as a Pub — it stays +// a structural candidate of Pub.publish. +export class Duck { + publish(e: { id: string }, trace?: string): void {} +} + +// Built beside a Pub and the right arity, but its publish is private: not assignable to Pub. +export class Hidden { + private publish(e: { id: string }): void {} +} +export const hidden = new Hidden(); + +export function wire(): void { + const p: Pub = new Duck(); + new Relay(p).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts new file mode 100644 index 000000000..cc19ffdeb --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -0,0 +1,27 @@ +// A shared method NAME is not conformance. Pub has a declared implementor (Real) and a +// conformer the program builds and passes as a Pub (Duck, in duck-pub.ts). Two more +// classes share the name `publish` and are NOT Pubs: +// * Bus (bus.ts) sees Pub but its publish needs two arguments — not assignable; +// * Stranger (stranger.ts) has the right arity but no module that holds it ever +// imports this one, so no instance of it can reach a Pub-typed slot. +// The control for Stranger is Declared (declared.ts): it never reaches this module either, +// but it imports a Pub from a package the walk cannot follow, so it stays a candidate. + +export interface Pub { + publish(e: { id: string }): void; +} + +export class Relay { + constructor(private readonly p: Pub) {} + run(): void { + this.p.publish({ id: "1" }); + } +} + +export class Real implements Pub { + publish(e: { id: string }): void {} +} + +export function start(): void { + new Relay(new Real()).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts new file mode 100644 index 000000000..39267f860 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts @@ -0,0 +1,7 @@ +// Same shape as Pub, but nothing that holds a Stranger ever imports pub.ts: no Stranger +// can be handed to code typed by Pub. +export class Stranger { + publish(e: { id: string }): void {} +} + +export const stranger = new Stranger(); diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts index 198ebefae..10e32c63f 100644 --- a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 000000000..3f31a5673 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42) diff --git a/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts new file mode 100644 index 000000000..e462aa74d --- /dev/null +++ b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts @@ -0,0 +1,17 @@ +class Repo { append(x: number): number { return x; } } +class Other { append(x: number): number { return x; } } +interface Ctx { repo: Repo } + +// A declared `this` parameter is what `this` is inside the function, whoever binds it. +function onEvent(this: Ctx, e: number) { return this.repo.append(e); } +function onShape(this: { other: Other }, e: number) { return this.other.append(e); } +const h = onEvent.bind({ repo: new Repo() }); +const s = onShape.bind({ other: new Other() }); + +// controls: an untyped `this` stays unknown; a class member's `this` is still its class +function onUntyped(this: any, e: number) { return this.repo.append(e); } +class Owner { + repo = new Repo(); + run(e: number) { return this.repo.append(e); } +} +export { h, s, onUntyped, Owner }; diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index f5857b6e4..46193873d 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,9 +1,23 @@ ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> - +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.entries b/graph/test/typescript/expected/03-structural-satisfaction.entries new file mode 100644 index 000000000..1dc8130f9 --- /dev/null +++ b/graph/test/typescript/expected/03-structural-satisfaction.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module duck#consume duck.ts:31 + exported_from_entry_module duck#drive duck.ts:35 + exported_from_entry_module duck#useLibrary duck.ts:45 + exported_from_entry_module duck-pub#wire duck-pub.ts:15 + unimported_module bus# bus.ts:1 + unimported_module declared# declared.ts:1 + unimported_module duck# duck.ts:1 + unimported_module duck-pub# duck-pub.ts:1 + unimported_module stranger# stranger.ts:1 diff --git a/graph/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope index 224a7991e..e5fbd8c49 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,2 +1,5 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read +structural pub#Pub.publish -> declared#Declared.publish +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields b/graph/test/typescript/expected/03-structural-satisfaction.fields index e69de29bb..7cdc0dfc1 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields @@ -0,0 +1 @@ +known_edge read Relay#run() -> Relay#p diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle index 05a32ce61..54cda29c4 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [fields] - precision 0.0000 (0 correct, 0 wrong) - recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index 258f2417b..ac4f8304f 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,9 +1,23 @@ boundary_lib FUNCTION_CALL duck#useLibrary() @L51 -> io#drain({ read(): string }) boundary_lib METHOD_CALL duck#useLibrary() @L50 -> Closeable#close() +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope index 595d139aa..620432e53 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -1,3 +1,6 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read structural lib:io#Closeable.close -> duck#FileReader.close +structural pub#Pub.publish -> declared#Declared.publish +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle index 3716c5f08..e196b8052 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle @@ -1,4 +1,7 @@ -oracle=7 engine=10 agree=7 missing=0 (known 0, NEW 0) extra=3 +oracle=18 engine=24 agree=18 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle index 110a730c3..7546b16d7 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,7 @@ -oracle=5 engine=8 agree=5 missing=0 (known 0, NEW 0) extra=3 +oracle=16 engine=22 agree=16 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index 83c409c6a..04be63a89 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,4 +1,17 @@ +ambiguous_unknown IMPLEMENTS_INTERFACE 0 Declared [HERITAGE] -> - ambiguous_unknown VARIABLE_TYPE 0 duck [VARIABLE] -> - +known_edge IMPLEMENTS_INTERFACE 0 Real [HERITAGE] -> Pub +known_edge METHOD_PARAM 0 Relay [METHOD_PARAM] -> Pub known_edge METHOD_PARAM 0 duck [METHOD_PARAM] -> Reader +known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 declared [EXPRESSION] -> Declared known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Hidden +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Real +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 stranger [EXPRESSION] -> Stranger +known_edge TYPE_ALIAS_RHS 0 Seen [TYPE] -> Pub +known_edge VARIABLE_TYPE 0 duck-pub [VARIABLE] -> Pub diff --git a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle index de06659f1..d2852d663 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [types] - precision 1.0000 (3 correct, 0 wrong) - recall 1.0000 (3 of 3 the compiler resolved) - sites 5 resolved 4 (80.0%) - tiers ambiguous_unknown=1 known_edge=4 - contexts METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 VARIABLE_TYPE=1 - not scored: 1 rows whose target is not a client declaration + precision 1.0000 (15 correct, 0 wrong) + recall 1.0000 (15 of 15 the compiler resolved) + sites 18 resolved 16 (88.9%) + tiers ambiguous_unknown=2 known_edge=16 + contexts IMPLEMENTS_INTERFACE=2 METHOD_PARAM=2 OBJECT_CREATION_TYPE=11 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges index 4c3712657..52529f7bd 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -1,14 +1,24 @@ ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +ambient_terminal FUNCTION_CALL plugin#() @L12 -> plugin#wrap(T) +ambient_terminal FUNCTION_CALL plugin#() @L7 -> plugin#wrap(T) callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered FUNCTION_CALL plugin#() @L7 -> plugin#() +callback_registered METHOD_CALL app#boot() @L57 -> plugin#() +callback_registered METHOD_CALL app#bootBare() @L62 -> app#() callback_registered METHOD_CALL app#doubled(number[]) @L16 -> app#(?) callback_registered METHOD_CALL app#named(number[]) @L25 -> app#double(number) callback_registered METHOD_CALL app#scaled(number[]) @L12 -> app#(?) callback_registered METHOD_CALL app#viaLocal(number[]) @L31 -> app#log(number) known_edge FUNCTION_CALL app#() @L38 -> app#record(number) known_edge FUNCTION_CALL app#(?) @L48 -> app#record(number) +known_edge FUNCTION_CALL app#() @L62 -> plugin#migrate() known_edge FUNCTION_CALL app#each(number[],(n: number) =) @L44 -> app#(number) known_edge FUNCTION_CALL app#log(number) @L30 -> app#record(number) known_edge FUNCTION_CALL app#useEach(number[]) @L48 -> app#each(number[],(n: number) =) +known_edge FUNCTION_CALL plugin#() @L8 -> plugin#migrate() +known_edge METHOD_CALL app#boot() @L57 -> app#register(unknown) +known_edge METHOD_CALL app#bootBare() @L62 -> app#register(unknown) +known_edge METHOD_CALL app#bootSettings() @L67 -> app#register(unknown) known_edge METHOD_CALL app#doubled(number[]) @L16 -> Array#map((v: T) =) known_edge METHOD_CALL app#named(number[]) @L25 -> Array#map((v: T) =) known_edge METHOD_CALL app#scaled(number[]) @L12 -> Array#map((v: T) =) diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries index 5c3d7421a..79860ca6f 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -1,5 +1,8 @@ -── entry_point (9) ── +── entry_point (12) ── exported_from_entry_module app#atStartup app.ts:37 + exported_from_entry_module app#boot app.ts:56 + exported_from_entry_module app#bootBare app.ts:61 + exported_from_entry_module app#bootSettings app.ts:66 exported_from_entry_module app#doubled app.ts:15 exported_from_entry_module app#each app.ts:43 exported_from_entry_module app#named app.ts:24 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle index f31a2b293..e6f0ab28d 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -1 +1 @@ -oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 +oracle=16 engine=16 agree=16 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.edges b/graph/test/typescript/expected/84-declared-this-parameter.edges new file mode 100644 index 000000000..184ea7af5 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.edges @@ -0,0 +1,9 @@ +ambiguous_unknown METHOD_CALL x#() @L8 -> - +ambiguous_unknown METHOD_CALL x#() @L9 -> - +ambiguous_unknown METHOD_CALL x#onUntyped(number) @L12 -> - +known_edge CONSTRUCTOR_CALL x#() @L14 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L8 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L9 -> Other#() +known_edge METHOD_CALL Owner#run(number) @L15 -> Repo#append(number) +known_edge METHOD_CALL x#onEvent(number) @L6 -> Repo#append(number) +known_edge METHOD_CALL x#onShape(number) @L7 -> Other#append(number) diff --git a/graph/test/typescript/expected/84-declared-this-parameter.entries b/graph/test/typescript/expected/84-declared-this-parameter.entries new file mode 100644 index 000000000..8e41e4b47 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.entries @@ -0,0 +1,3 @@ +── entry_point (2) ── + exported_from_entry_module x#onUntyped x.ts:12 + unimported_module x# x.ts:1 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields b/graph/test/typescript/expected/84-declared-this-parameter.fields new file mode 100644 index 000000000..de3e89d91 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields @@ -0,0 +1,4 @@ +ambiguous_unknown read x#onUntyped(number) -> - +known_edge read Owner#run(number) -> Owner#repo +known_edge read x#onEvent(number) -> Ctx#repo +known_edge read x#onShape(number) -> { other: Other }#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle new file mode 100644 index 000000000..0a3c5b6fb --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle @@ -0,0 +1,9 @@ +84-declared-this-parameter [fields] + precision 0.6667 (2 correct, 1 wrong) + recall 0.6667 (2 of 3 the compiler resolved) + sites 4 resolved 3 (75.0%) + tiers ambiguous_unknown=1 known_edge=3 + access read=4 + not scored: 1 rows whose target is not a client declaration + WRONG x#onShape(number) READ { other: Other }#other + MISSING x#onShape(number) READ x#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.oracle b/graph/test/typescript/expected/84-declared-this-parameter.oracle new file mode 100644 index 000000000..6c4bb5cbb --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.oracle @@ -0,0 +1 @@ +oracle=5 engine=5 agree=5 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.type-use b/graph/test/typescript/expected/84-declared-this-parameter.type-use new file mode 100644 index 000000000..f183c671d --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.type-use @@ -0,0 +1,6 @@ +known_edge FIELD_TYPE 0 Ctx [FIELD] -> Repo +known_edge FIELD_TYPE 1 x [METHOD_PARAM] -> Other +known_edge METHOD_PARAM 0 x [METHOD_PARAM] -> Ctx +known_edge OBJECT_CREATION_TYPE 0 Owner [EXPRESSION] -> Repo +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Other +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Repo diff --git a/graph/test/typescript/expected/84-declared-this-parameter.types-oracle b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle new file mode 100644 index 000000000..d0200fc91 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle @@ -0,0 +1,7 @@ +84-declared-this-parameter [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 6 resolved 6 (100.0%) + tiers known_edge=6 + contexts FIELD_TYPE=2 METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index ef0341117..907c664c2 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -204,7 +204,10 @@ export function loadProgram(srcDir, libDir, toolName, programDir) { const mods = ts.canHaveModifiers(decl) ? (ts.getModifiers(decl) ?? []) : []; if (mods.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) name = 'static ' + name; - const ps = (decl.parameters ?? []).map((param) => { + // A declared `this` parameter types `this`, it is no argument: the engine's label + // leaves it out, so this one does too. + const ps = (decl.parameters ?? []).filter((param) => + !(ts.isIdentifier(param.name) && param.name.text === 'this')).map((param) => { let t = param.type ? param.type.getText(sf) : '?'; if (param.dotDotDotToken && !t.endsWith('[]')) t += '[]'; return simple(t); diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index 7e0a11fc4..c049ce6d2 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -169,6 +169,16 @@ expr_type(e, "client", t) :- expr_referenced("client", "THIS", _, e), expr_enclosing_type(e, t). expr_type(e, "client", t) :- expr_kind("client", "THIS_REFERENCE", _, e), expr_enclosing_type(e, t). +// A declared `this` parameter (`function onEvent(this: Ctx, e: Evt)`) is what `this` is in +// that function, whoever binds it (`onEvent.bind(ctx)`, `.call(ctx)`, a framework): the +// compiler types `this.repo` from it, and without it the site had no receiver type at all. +expr_type(e, prov, t) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_type(p, prov, t). +expr_shape(e, s) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_shape_target(p, s). +this_expr(e) :- expr_referenced("client", "THIS", _, e). +this_expr(e) :- expr_kind("client", "THIS_REFERENCE", _, e). +method_declared_this(m, p) :- param_shape("client", "THIS", _, _, p), param_decl("client", _, _, _, m, p). // `this` INSIDE A STATIC METHOD IS THE CLASS, so its members are the STATICS. A helper // class that calls its own statics through `this` is ordinary TypeScript — diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index 2753406b4..c2ab16dfe 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -17,10 +17,14 @@ // // Keeping it off the primary path is deliberate. It is the one relation in this // engine that could FABRICATE rather than over-approximate, because it is name-based -// and does not compare member TYPES; a class with a `read()` that takes different -// arguments would still be counted as satisfying `Readable`. That is acceptable for -// widening a prune-only test and for a labelled reachability fan; it would not be -// acceptable as the answer to "what does this call resolve to". +// and does not compare member TYPES; a class with a `read(s: string)` would still be +// counted as satisfying `Readable { read(n: number) }`. Two cheap tests keep the worst +// of it out: a method that needs MORE arguments than the target passes is rejected +// (sat_arity_conflict), and a class no code can ever hand to the interface's slot is +// not an implementor (sat_can_meet). That is acceptable for widening a prune-only test +// and for a labelled reachability fan; it would not be acceptable as the answer to +// "what does this call resolve to", and a consumer never treats the `structural` basis +// as a declared contract. // // ── THE SOUNDNESS ARGUMENT, STATED ────────────────────────────────────────── // Direction: SOURCE is assignable to TARGET when the source has a member for every @@ -98,13 +102,68 @@ sat_covered(s, t, name) :- sat_pair_seed(s, t), sat_cover_count(s, t, n) :- sat_pair_seed(s, t), n = count : { sat_covered(s, t, _) }. +// ── sat_arity_conflict(Source, Target) — a shared name that cannot be called the same way +// A NAME MATCH IS NOT A MEMBER MATCH. `publish(name, payload)` shares a name with the +// interface's `publish(envelope)` and is not assignable to it: a method that needs more +// arguments than the target's signature passes is rejected by the compiler, whatever +// the types. So a covered METHOD name conflicts when no source method of that name +// needs at most as many arguments as some target method of that name takes. Fewer +// required parameters is fine (`read()` satisfies `read(n: number)`), and so is an +// optional or rest parameter. Only a pair where BOTH sides have arity facts can conflict: +// a member without them (a field holding a function) is left to the name test. +sat_target_method(t, name, m) :- target_required_member(t, name), + scope_sibling(t, sib), + declared_method(sib, name, "false", m). +sat_target_method(t, name, m) :- target_required_member(t, name), + group_of(t, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + scope_sibling(s, sib), + declared_method(sib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + group_of(s, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_arity_ok(s, t, name) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + sat_target_method(t, name, tm), + method_min_arity(sm, lo), + method_max_arity(tm, hi), + lo <= hi. +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + method_min_arity(sm, _), + sat_target_method(t, name, tm), + method_max_arity(tm, _), + !sat_arity_ok(s, t, name). +// A `private` or `protected` method never satisfies an interface member of its name: the +// compiler rejects the assignment ("property is private in type S but not in type T"), +// so a class whose only method of that name is hidden is not a conformer. A covered name +// the class also declares visibly (or as a field) is left alone. +sat_member_hidden("PRIVATE_ACCESS"). +sat_member_hidden("PROTECTED_ACCESS"). +sat_member_hidden("PRIVATE_NAME_ACCESS"). +sat_visible_source_method(s, name) :- sat_source_method(s, name, m), + method_access(_, acc, m), + !sat_member_hidden(acc). +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, m), + method_access(_, acc, m), + sat_member_hidden(acc), + !sat_visible_source_method(s, name). + // ── type_satisfies(SourceTypeHash, TargetTypeHash) ────────────────────────── -// Every required member covered. `k > 0` excludes the empty interface, which +// Every required member covered, and no covered method that could not be called with +// the target's arguments. `k > 0` excludes the empty interface, which // everything satisfies and which therefore carries no information — admitting it // would make every class an implementor of every marker interface in the tree. type_satisfies(s, t) :- sat_cover_count(s, t, k), target_required_count(t, k), - k > 0. + k > 0, + !sat_arity_conflict(s, t). // A nominal implements clause is satisfaction too, and it is the authoritative kind: // the programmer asserted it and the compiler checked it. Included here so consumers @@ -121,7 +180,8 @@ structural_implementor(t, s) :- type_satisfies(s, t), satisfaction_target(t), s != t, !target_has_nominal_implementor(t), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). // ── sat_cross_program(Source, Target) — a shape match no value can cross (#1574) ── // A CLIENT GLOBAL belongs to one program (module-graph.dl). A class in ANOTHER program @@ -204,7 +264,66 @@ structural_implementor(t, s) :- type_satisfies(s, t), target_has_nominal_implementor(t), !implementors(t, s), type_instantiated(s, _), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). + +// ── sat_can_meet(Source, Target) — can a value of the class ever reach the interface's slot +// A SHAPE MATCH BETWEEN TWO CODEBASES THAT NEVER MEET IS NOT A CONFORMANCE. In a monorepo +// one service's event bus and another service's publisher interface share a method name, +// and nothing in either program can hand the one to the other. For an instance of S to +// flow into a slot typed T, some code has to see both: S's own module, or a module that +// uses S as a value (`new S()`, `useClass: S`), must import T's module, directly or +// through other modules and re-exports. Admitted without the walk: +// * S and T in one tsconfig program, or in one module — the program sees both; +// * T a library interface, or either side in a global script — no import is needed +// to see it, so the import graph proves nothing. +// A program-level dependency is deliberately NOT enough: a test that boots two services +// together makes each app depend on the other, and every name the two share would come +// back. The import walk runs only for the pairs left, from the modules that hold S. +sat_meet_pair(s, t) :- type_satisfies(s, t), + satisfaction_target(t), + s != t, + !implementors(t, s). +sat_client_type(t) :- type_module("client", _, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_program(s, p), type_program(t, p). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), type_module("client", m, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), !sat_client_type(t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), module_is_global("client", m). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, t), module_is_global("client", m). +sat_meet_open(s, t) :- sat_meet_pair(s, t), !sat_meets_trivially(s, t). + +sat_holder_module(s, m) :- sat_meet_open(s, _), type_module("client", m, s). +sat_holder_module(s, m) :- sat_meet_open(s, _), + expr_referenced("client", "TYPE", s, e), + expr_module("client", m, e). +sat_module_dep(a, b) :- import_binding("client", _, _, _, a, h), + import_target_module(h, "client", b). +sat_module_dep(a, b) :- export_decl("client", _, _, _, a, h), + export_target_module(h, "client", b). +sat_module_reach(m, m) :- sat_holder_module(_, m). +sat_module_reach(o, b) :- sat_module_reach(o, a), + sat_module_dep(a, b). + +sat_can_meet(s, t) :- sat_meets_trivially(s, t). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + type_module("client", mt, t), + sat_module_reach(o, mt). +// THE WALK STOPS WHERE AN IMPORT LEADS OUT OF THE CLIENT: a workspace package whose +// manifest points at build output that is not there, or at a compiled .d.ts, binds no +// client module, so the walk cannot tell whether T's module is behind it. An import +// there that NAMES the interface is the evidence instead: the class's module, or one it +// reaches, brings in a type called T from a place the walk cannot follow. +sat_opaque_import_name(m, name) :- import_binding("client", _, name, _, m, h), + !import_target_module(h, "client", _). +sat_opaque_import_name(m, name) :- import_binding("client", _, _, name, m, h), + name != "", + !import_target_module(h, "client", _). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + sat_module_reach(o, m), + sat_opaque_import_name(m, name), + type_decl("client", name, _, _, _, _, t). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index b8c6aba61..439dac718 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -236,6 +236,34 @@ handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUM value_branch(a, x), method_value(x, m). +// …AND WHAT A LIBRARY CALL WRAPPED, KEPT IN A HOLDER. `export const plugin = fp(async (app) => …)`, then +// `app.register(plugin)`: the holder keeps what a call with no client body RETURNED, so no holder rule above +// sees a function in it, and the registration reached nothing although the same literal handed to it bare +// is reached. A library wrapper hands back the function it was handed, or one that runs it (`fp`, +// `defineExtension`, `debounce`), so the function the wrapping site was handed is what the holder hands on. +// Only a wrapping site at the library boundary: a project wrapper has a body, and what it returns is +// followed through that body (fn_value_call, below). The const is named here or imported from the +// module that wraps it (expr_holder), which is the usual shape: a plugin module, a registering app. +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_holder(x, h), + holder_wraps_handed_function(h, m). +// A const's own initializer only, and only a call of a declaration, not of a value the project holds: a +// reassignment (`state = createState(set, get)`) and a call through a parameter hand back what the project +// function returns, which is data as often as it is the function it was handed. +// And not a call whose result the types say is data: `setTimeout(cb)` returns a timer, `xs.filter(cb)` an +// array, and `clearTimeout(timer)` runs nothing. A result typed with no call signature is data; an untyped +// one (a package with no types staged) or a callable one (`fp`'s plugin type, a mock) is kept. +holder_wraps_handed_function(h, m) :- var_initializer("client", _, e, h), e != "", + value_branch(e, w), + expr_kind("client", "CALL_EXPRESSION", _, w), + hof_boundary_site(w), + !called_through(w, _), + !call_returns_data(w), + handed_function(w, m). +call_returns_data(w) :- expr_type(w, _, t), !call_signature_in_scope(t, _), !call_result_callable(w). +call_result_callable(w) :- expr_shape(w, s), call_signature_in_scope(s, _). + // ── method_value(Expr, Method): an INSTANCE method read as a value, not called ── // `xs.forEach(this.handle, this)`, `el.addEventListener('click', this.onClick)`, // `setTimeout(this.onHover.bind(this))`. expr_callable names a free function, an import and diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb37..7826d7367 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -216,6 +216,9 @@ .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl holder_wraps_handed_function(c0:symbol,c1:symbol) // (holder, fn): the holder keeps what a library call returned, and fn was handed to that call +.decl call_returns_data(c0:symbol) // the call's result is typed, and the type has no call signature +.decl call_result_callable(c0:symbol) .decl method_value(c0:symbol,c1:symbol) .decl method_is_accessor(c0:symbol) .decl called_through(c0:symbol,c1:symbol) @@ -370,6 +373,8 @@ .decl method_signature_role(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_signature_role_of(c0:symbol,c1:symbol) .decl method_this_param(c0:symbol) +.decl this_expr(c0:symbol) +.decl method_declared_this(c0:symbol,c1:symbol) .decl module_ambient_specifier(c0:symbol,c1:symbol,c2:symbol) .decl module_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl module_default_export(c0:symbol,c1:symbol,c2:symbol) @@ -709,3 +714,18 @@ .decl dispatch_assumes_closed_world(c0:symbol,c1:symbol) .decl type_constructible(c0:symbol) .decl type_live(c0:symbol) +.decl sat_arity_conflict(c0:symbol,c1:symbol) +.decl sat_arity_ok(c0:symbol,c1:symbol,c2:symbol) +.decl sat_can_meet(c0:symbol,c1:symbol) +.decl sat_client_type(c0:symbol) +.decl sat_holder_module(c0:symbol,c1:symbol) +.decl sat_member_hidden(c0:symbol) +.decl sat_meet_open(c0:symbol,c1:symbol) +.decl sat_meet_pair(c0:symbol,c1:symbol) +.decl sat_meets_trivially(c0:symbol,c1:symbol) +.decl sat_module_dep(c0:symbol,c1:symbol) +.decl sat_module_reach(c0:symbol,c1:symbol) +.decl sat_opaque_import_name(c0:symbol,c1:symbol) +.decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_visible_source_method(c0:symbol,c1:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ad8cebbae..fbd91552f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1220,8 +1220,11 @@ class Impact: # implements `Router.add` whether or not anything constructs a TrieRouter, and a signature change breaks it # either way. Read straight from dispatch_candidates so the contract rule does not inherit the closure's # filter — which is what made the rules and the hook's fast path disagree on exactly those candidates. + # A `structural` pair is a shape match nobody declared: evidence that the class MAY be passed as the + # interface, not that it implements it, so it never makes the declaration a must-change contract. W('implements_pair', sorted({(r[0], r[1]) for r in g.q( - "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id")}) + "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id" + " AND basis <> 'structural'")}) if g.has('dispatch_candidates') else []) # the pairs whose base is a FUNCTION TYPE and whose candidate is a function stored in a field of it (#1206): # the callers of the base call the candidate through that field (impact.dl, `value_pair`) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0d0c9cec6..0cd36133a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -75,6 +75,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 = {''} @@ -476,6 +478,22 @@ class G: # body's), and the first row came back whatever the line, so a module-level constant below a class was # answered as that class's body r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL ORDER BY (line <= ? AND COALESCE(end_line, line) >= ?) DESC, COALESCE(end_line, line) - line LIMIT 1", f, ln, ln) + # A LINE INSIDE A TYPE'S BODY BUT IN NONE OF ITS MEMBERS is not top-level code. A blank or comment line between + # two methods — a line number copied before the file was edited — was answered as the whole file's module + # where a class body has no module node of its own (TypeScript, Java, C#), with no word that the line holds + # nothing. A Python class body IS a module node inside the type, and still answers as before. + ty = self.q("SELECT id, display, line, end_line FROM symbols WHERE file = ? AND type_id IS NOT NULL AND method_id IS NULL AND line < ? AND end_line > ? ORDER BY end_line - line LIMIT 1", f, ln, ln) + mod = self.sym.get(r[0]['id'], {}) if r else {} + if ty and not (r and ty[0]['line'] <= (mod.get('line') or 0) and (mod.get('end_line') or 0) <= ty[0]['end_line']): + t = ty[0] + mem = self.q("SELECT id, line, end_line FROM symbols WHERE file = ? AND line > ? AND end_line < ? AND kind <> 'module' AND (method_id IS NOT NULL OR kind IN ('field','const','enum_member','variable'))", f, t['line'], t['end_line']) + above = max((x for x in mem if (x['end_line'] or x['line']) < ln), key=lambda x: (x['end_line'] or x['line'], -x['line']), default=None) + below = min((x for x in mem if x['line'] > ln), key=lambda x: (x['line'], x['line'] - (x['end_line'] or x['line'])), default=None) + near = [f" {self.name(x['id'])} {f}:{x['line']}" for x in (above, below) if x] + die(f"line {ln} of {f} is inside {t['display']} ({f}:{t['line']}-{t['end_line']}) but in none of its declarations" + " (a blank, comment or separator line — often a line number from before an edit)." + + ("\n the nearest declarations:\n" + '\n'.join(near) if near else '') + + f"\n ask for one of them, by name or by its line; `{t['display']}` asks about the whole type") if r: self.why_note('file and line: top-level code', [r[0]['id']], f"no callable spans line {ln}; the file's top-level code holding it is the endpoint") return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] @@ -1920,8 +1938,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: @@ -1931,8 +1959,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. @@ -1974,15 +2001,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/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index b781a96c9..ce5ae73f3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -950,11 +950,15 @@ def via_base_rows(q, lines=None, stubs=frozenset(), only=None): carries no edge to b: that caller is a caller of b when the receiver it reads there is a field declared with b's type. Without it, `impact` on the interface method said nothing depended on it (#1542).""" if not (_has(q, 'call_edges') and _has(q, 'call_sites')): return [], set() - pairs = set() + pairs, shape_only = set(), set() if _has(q, 'overrides'): pairs |= {(b, o) for b, o in q("SELECT method_id, overriding_method_id FROM overrides")} if _has(q, 'dispatch_candidates'): - pairs |= {(b, o) for b, o in q("SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE basis <> 'value'")} + declared = set(pairs) + for b, o, basis in q("SELECT base_method_id, candidate_method_id, basis FROM dispatch_candidates WHERE basis <> 'value'"): + pairs.add((b, o)) + (shape_only if basis == 'structural' else declared).add((b, o)) + shape_only -= declared # a shape match nobody declared is never the one thing that runs down = collections.defaultdict(set) for b, o in pairs: if b and o and b != o: down[b].add(o) @@ -1020,7 +1024,8 @@ def recv_types(c, sp): others = set(T) - {b} for m in subs[b]: if others and m not in others: continue - rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if n == 1 else 'one of a set', f, l, m if m in T else b)) + sole = n == 1 and (b, m) not in shape_only + rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if sole else 'one of a set', f, l, m if m in T else b)) sites.add((c, m, f, l)) if typed_on or len(T) != 1: continue (o, t), = T.items() @@ -1715,10 +1720,12 @@ def contract_for_method(q, ids): if not q("SELECT 1 FROM overrides LIMIT 1"): out += _name_match_contract(q, ids) # the dispatch base the engine records no override row for (#1011): read from dispatch_candidates UNFILTERED, # because whether a declaration implements an interface method is not a question about reachability — the rules - # read `implements_pair`, which is the same table without the closure's RTA filter. + # read `implements_pair`, which is the same table without the closure's RTA filter. A `structural` pair is a + # shape match nobody declared, so it is not a contract (axiomcode-impact excludes it from implements_pair too). if q("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'"): for (b,) in q(f"""SELECT DISTINCT dc.base_method_id FROM dispatch_candidates dc WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis <> 'structural' AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): if b not in ids: out.append((b, 'it implements this — the engine records a dispatch candidate here and no override row')) diff --git a/tests/cases/javascript/fileline-line-outside-file/case.json b/tests/cases/javascript/fileline-line-outside-file/case.json index 2dae3ef39..7e0cde15a 100644 --- a/tests/cases/javascript/fileline-line-outside-file/case.json +++ b/tests/cases/javascript/fileline-line-outside-file/case.json @@ -36,6 +36,19 @@ "run": ["path", "*", "src/services/userService.js:2"], "want": ["getUser"], "avoid": ["is not in it"]}, + {"why": "a blank line between two members of a class is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/services/cart.js:3"], + "expect_error": true, + "want": ["line 3 of src/services/cart.js is inside Cart", "Cart.add src/services/cart.js:5"], + "avoid": [""]}, + {"why": "control: the class header line still answers for the class", + "run": ["impact", "src/services/cart.js:1"], + "want": ["Cart"], + "avoid": ["is inside Cart", "change: cart."]}, + {"why": "control: the line after the class is still the file's top-level code", + "run": ["impact", "src/services/cart.js:7"], + "want": ["cart. (at src/services/cart.js:7)"], + "avoid": ["is inside Cart"]}, {"why": "control: a file the index does not hold is still refused as before", "run": ["impact", "zzz/userService.js:99"], "expect_error": true, diff --git a/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js new file mode 100644 index 000000000..159f3f1b3 --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js @@ -0,0 +1,8 @@ +class Cart { + constructor() { this.items = []; } + + /** adds one */ + add(x) { this.items.push(x); } +} +new Cart().add('a'); +module.exports = { Cart }; 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/dispatch-base-is-a-contract/case.json b/tests/cases/typescript/dispatch-base-is-a-contract/case.json index 6467788c5..0afd462a6 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/case.json +++ b/tests/cases/typescript/dispatch-base-is-a-contract/case.json @@ -11,5 +11,9 @@ {"why": "the contract holds whether or not anything constructs the implementation: TrieRouter is never instantiated, so the closure's RTA filter drops its dispatch edge, and reading the implements relation through that filter made the rules and the fast path disagree", "run": ["impact", "TrieRouter.add"], "want": ["Router.add", "it implements this"], - "avoid": []} + "avoid": []}, + {"why": "a class that only matches the interface's SHAPE is a dispatch candidate, not a declared contract: its callers are still reached through the base, but the base is never 'must change - it implements this'", + "run": ["impact", "DuckRouter.add"], + "want": ["App.mount"], + "avoid": ["it implements this", "must change with it"]} ]} diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts index f7a0bf473..6a82bf5cb 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts +++ b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts @@ -25,3 +25,16 @@ export class App { this.router.add(path); // typed to the interface: the dispatch base } } + +// No `implements`: it fits Router's shape and is passed as one, so it may run at `mount` — but nothing +// declared the contract, so a change to it does not have to change Router.add. +export class DuckRouter { + add(path: string): void { + this.last = path; + } + last = ''; +} + +export function duckApp(): App { + return new App(new DuckRouter()); +} diff --git a/tests/cases/typescript/fileline-dotted-basename/case.json b/tests/cases/typescript/fileline-dotted-basename/case.json index 701c6d748..7efe90b68 100644 --- a/tests/cases/typescript/fileline-dotted-basename/case.json +++ b/tests/cases/typescript/fileline-dotted-basename/case.json @@ -23,4 +23,22 @@ {"why": "path takes a ./ file:line", "run": ["path", "*", "./src/user.service.ts:3"], "want": ["UserController.get"], - "avoid": ["no callable spans"]}]} + "avoid": ["no callable spans"]}, + {"why": "a blank line inside a class body, between two members, is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/cart.ts:3"], + "expect_error": true, + "want": ["line 3 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": [""]}, + {"why": "the same for a member's doc-comment line, through path", + "run": ["path", "*", "src/cart.ts:4"], + "expect_error": true, + "want": ["line 4 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": ["top-level code"]}, + {"why": "control: the member's own line still answers for it", + "run": ["impact", "src/cart.ts:5"], + "want": ["change: Cart.add (at src/cart.ts:5)"], + "avoid": ["is inside Cart"]}, + {"why": "control: a line after the class is still the file's top-level code", + "run": ["impact", "src/cart.ts:7"], + "want": ["cart. (at src/cart.ts:7)"], + "avoid": ["is inside Cart"]}]} diff --git a/tests/cases/typescript/fileline-dotted-basename/src/cart.ts b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts new file mode 100644 index 000000000..8b0c3aa94 --- /dev/null +++ b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts @@ -0,0 +1,7 @@ +export class Cart { + private items: string[] = []; + + /** adds one */ + add(x: string): void { this.items.push(x); } +} +new Cart().add('a'); 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) } +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/case.json b/tests/cases/typescript/hof-callback-at-library-boundary/case.json index 4f1d456b7..d59b77d3f 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/case.json +++ b/tests/cases/typescript/hof-callback-at-library-boundary/case.json @@ -61,6 +61,46 @@ "no chain of resolved calls" ] }, + { + "why": "a function literal wrapped by a library call and kept in a const (`const plugin = wrap(async () => …)`), then handed to a library registration (`host.register(plugin)`), is reached from the function that registers it, as the literal handed bare is; it was 'the two are independent in this graph'", + "run": [ + "path", + "boot", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ], + "avoid": [ + "the two are independent" + ] + }, + { + "why": "CONTROL: the same literal handed to the registration bare is reached as before", + "run": [ + "path", + "bootBare", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ] + }, + { + "why": "CONTROL: a const holding what a library call built from a plain value registers no function", + "run": [ + "path", + "bootSettings", + "migrate" + ], + "want": [], + "avoid": [ + "1 of 1 target(s) reached" + ], + "expect_error": true + }, { "why": "CONTROL: a project higher-order function has a body that calls its parameter, so the callback it is handed is still reached through that call", "run": [ diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts index 198ebefae..10e32c63f 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 000000000..3f31a5673 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42)