diff --git a/gemini-extension.json b/gemini-extension.json index 2c6ca0db2..6327e15c3 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.6", + "version": "0.1.7", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/graph/javascript/engine/call-edge-generation/call_chain.dl b/graph/javascript/engine/call-edge-generation/call_chain.dl index 7aca8634f..c08d683e5 100644 --- a/graph/javascript/engine/call-edge-generation/call_chain.dl +++ b/graph/javascript/engine/call-edge-generation/call_chain.dl @@ -103,6 +103,7 @@ object_prototype_call_on_bare_type(ce) :- invocation_site(ce, ck), call_kind_is_ // Python rule sets: a capped site is a declared unknown (`fan_capped`), never a // silent drop and never a 70-way edge set a reader would have to distrust. call_over_cap(ce) :- call_target_count(ce, n), dispatch_cap(s), n > to_number(s). +call_over_cap(ce) :- open_dispatch_width(ce, n), dispatch_cap(s), n > to_number(s). // A site whose callee was a computed read swallowed by a default has more callees // than the value layer found, so one resolved target is not THE target (#931). call_class(ce, "known_edge") :- call_target_count(ce, 1), !call_ambient_alternative(ce), @@ -111,14 +112,16 @@ call_class(ce, "multi_inferred") :- call_target_count(ce, 1), partial_dispatch(c partial_dispatch_target(ce, _). call_class(ce, "multi_inferred") :- call_target_count(ce, n), n >= 2, !call_over_cap(ce). call_class(ce, "multi_inferred") :- call_target_count(ce, 1), call_ambient_alternative(ce). +// An open read of an object-literal table (partial-dispatch.dl): one of its entries. +call_class(ce, "multi_inferred") :- open_dispatch(ce), !call_over_cap(ce). call_class(ce, "fan_capped") :- call_over_cap(ce). call_class(ce, "implicit_constructor") :- call_target_count(ce, 0), call_target_is_implicit_ctor(ce). call_class(ce, "dynamic_terminal") :- call_target_count(ce, 0), !call_target_is_implicit_ctor(ce), - invocation_site(ce, ck), call_kind_is_dynamic(ck). + invocation_site(ce, ck), call_kind_is_dynamic(ck), !open_dispatch(ce). call_class(ce, "ambient_terminal") :- call_target_count(ce, 0), !call_target_is_implicit_ctor(ce), - invocation_site(ce, ck), !call_kind_is_dynamic(ck), ambient_site(ce). + invocation_site(ce, ck), !call_kind_is_dynamic(ck), ambient_site(ce), !open_dispatch(ce). call_class(ce, "ambiguous_unknown") :- call_target_count(ce, 0), !call_target_is_implicit_ctor(ce), - invocation_site(ce, ck), !call_kind_is_dynamic(ck), !ambient_site(ce). + invocation_site(ce, ck), !call_kind_is_dynamic(ck), !ambient_site(ce), !open_dispatch(ce). // ── method_has_unknown_call(Caller, CallExpr) ─────────────────────────────── method_has_unknown_call(caller, ce) :- call_class(ce, "ambiguous_unknown"), call_from(ce, caller). diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index 55697c0d0..34fbd6b81 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -78,13 +78,116 @@ unresolved_receiver(ce, cn, ck, "other_form") :- call_site("client", ck, cn, _, // name narrows what it calls, so any function that escapes as a value may be the // callee, and a question that finds no chain from its caller must answer "unknown", // never "independent" (#1649). Binding: parameter | loop_variable | local | -// computed | dynamic_code. An imported binding is not one: it names a module -// member, and a module-level binding is left to the by-name leads. +// computed | dynamic_code | module_variable | field | instance_member | +// parameter_member | expression | imported_variable. An imported binding is one only +// when it names a variable its module reassigns (below); otherwise it names a module +// member. value_callee_open(ce, c) :- call_site("client", ck, _, _, _, _, ce, _, _), !call_kind_is_dynamic(ck), !call_resolved(ce), !callee_value_known(ce), expr_child(_, ce, "CALLEE", _, c). unresolved_value_callee(ce, "parameter") :- value_callee_open(ce, c), expr_param(_, _, c). unresolved_value_callee(ce, "loop_variable") :- value_callee_open(ce, c), expr_binding(_, v, c), loop_head(v, _). unresolved_value_callee(ce, "local") :- value_callee_open(ce, c), expr_binding(_, v, c), !loop_head(v, _), var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c). +// The two below keep a callee whose value IS known but calls nothing the graph holds +// (`let handler = null`, a constructed `Function`): the value in the graph is not +// the one the call runs. +value_callee_unresolved(ce, c) :- call_site("client", ck, _, _, _, _, ce, _, _), !call_kind_is_dynamic(ck), + !call_resolved(ce), expr_child(_, ce, "CALLEE", _, c). +// `let handler = null; … handler()`: a module-level variable holds whatever a setter +// or an importer stored in it (#1675). One holding a platform function +// (`let f = Math.floor`) is a platform call; `null` is a literal, not a function. +unresolved_value_callee(ce, "module_variable") :- value_callee_unresolved(ce, c), expr_binding(_, v, c), + var_decl("client", _, _, _, _, _, v), !var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c), + !callee_platform_function(ce), !module_variable_from_call(v). +callee_platform_function(ce) :- callee_value(ce, "ambient", n), n != "LITERAL". +// `const log = require('debug')('app')`: a variable only ever holding what a call +// returned holds a function the library made, not one a setter stored. One that +// was handed a function (`const Card = memo(Inner)`) may call it, so stays open. +module_variable_from_call(v) :- var_init("client", _, e, v), expr_kind(_, k, _, e), (k = "CALL" ; k = "NEW"), + !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). +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. +// `new Function(s)()` / `Function(s)()` runs code built at run time. +unresolved_value_callee(ce, "dynamic_code") :- value_callee_unresolved(ce, c), callee_builds_code(c). +unresolved_value_callee(ce, "expression") :- value_callee_open(ce, c), expr_kind(_, k, _, c), + k != "IDENTIFIER", k != "THIS", k != "SUPER", !callee_builds_code(c). +callee_builds_code(c) :- expr_kind(_, k, _, c), (k = "NEW" ; k = "CALL"), expr_child(_, c, "CALLEE", _, f), + expr_text(_, "Function", f), expr_binding_resolution(_, "GLOBAL_BUILTIN", f). +// `this.cb()` where `cb` is a field (`this.cb = cb`), not a method: the function +// stored there is whatever the constructor or a setter was handed. The same for +// `self.cb()` (`const self = this`), a static `this.cb()`, `new this.Ctor()`, and a +// field that holds only `null` until a setter runs. +unresolved_value_callee(ce, "field") :- field_call(ce, k, t, n), !call_resolved(ce), !callee_holds_function(ce), + this_field_named(k, t, n). +// `this.cb = cb || function () {}`: the call resolves to the fallback, but the +// parameter it falls back from is a function no caller in the graph supplies. +unresolved_value_callee(ce, "field") :- field_call(ce, k, t, n), field_open_fallback(k, t, n). +// `this.hook()` where neither the class, an ancestor, nor any write the graph can +// follow gives it a `hook`: the function is stored on the instance from outside, +// through another reference (`e.hook = fn`) or a copy (`Object.assign(this, opts)`) +// (#1709). A class with a base the graph could not resolve is left out: that base +// may declare `hook`, and then the call names a method, not a value. +unresolved_value_callee(ce, "instance_member") :- field_call(ce, "inst", t, n), !call_resolved(ce), + !callee_value_known(ce), !ambient_site(ce), !this_field_named("inst", t, n), !this_type_open(t), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "THIS", _, r). +this_type_open(t) :- heritage_unresolved(t, _, _). +this_type_open(t) :- heritage_dynamic(t, _). +this_type_open(t) :- type_ancestor(t, a), heritage_unresolved(a, _, _). +this_type_open(t) :- type_ancestor(t, a), heritage_dynamic(a, _). +field_call(ce, k, t, n) :- call_site("client", ck, n, "SYNTACTIC", _, _, ce, _, _), + (call_kind_is_member_form(ck) ; ck = "CONSTRUCTOR_CALL"), !call_kind_is_dynamic(ck), + expr_child(_, ce, "RECEIVER", _, r), instance_or_class_value(r, k, t). +instance_or_class_value(r, k, t) :- expr_kind(_, rk, _, r), (rk = "THIS" ; rk = "IDENTIFIER"), expr_value(r, k, t), + (k = "inst" ; k = "ctor"). +callee_holds_function(ce) :- callee_value(ce, k, n), (k = "func" ; k = "ctor" ; (k = "ambient", n != "LITERAL")). +callee_holds_function(ce) :- new_callee_value(ce, _, _). +this_field_named(k, t, n) :- field_decl("client", n, t, _, _, _, _, _), (k = "inst" ; k = "ctor"). +this_field_named(k, t, n) :- type_ancestor(t, a), field_decl("client", n, a, _, _, _, _, _), (k = "inst" ; k = "ctor"). +this_field_named(k, t, n) :- field_assignment(k, t, n, _). +field_assignment(k, t, n, val) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), + expr_kind(_, "PROPERTY_ACCESS", _, tgt), expr_name(_, n, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + instance_or_class_value(r, k, t), expr_child(_, a, "ASSIGNMENT_VALUE", _, val). +field_open_fallback(k, t, n) :- field_assignment(k, t, n, val), expr_kind(_, "BINARY", _, val), expr_operator(_, op, val), + (op = "||" ; op = "??"), expr_child(_, val, "OPERAND", _, p), expr_param(_, _, p), !expr_value(p, "func", _). +// A callback in an options object: a member of an untyped parameter that the code +// treats as an optional FUNCTION — `opts.onReady?.()`, `typeof opts.onReady === +// 'function'`, `opts.onReady && opts.onReady()`. A bare `res.send()` / `s.trim()` on +// an untyped parameter is left out: without a type it reads the same as a library +// or platform method, and counting it made 10 of 12 independent pairs on a real +// Express app unknown. +unresolved_value_callee(ce, "parameter_member") :- call_site("client", ck, n, "SYNTACTIC", _, _, ce, _, _), + call_kind_is_member_form(ck), !call_kind_is_dynamic(ck), !call_resolved(ce), !callee_value_known(ce), + !ambient_site(ce), expr_child(_, ce, "RECEIVER", _, r), expr_param(_, p, r), + (ck = "OPTIONAL_CALL" ; param_member_tested(p, n)). +param_member_tested(p, n) :- expr_kind(_, "UNARY", _, u), expr_operator(_, "typeof", u), expr_child(_, u, _, _, a), + param_member_read(a, p, n). +param_member_tested(p, n) :- expr_kind(_, "BINARY", _, b), expr_operator(_, op, b), (op = "&&" ; op = "||" ; op = "??"), + expr_child(_, b, "OPERAND", _, a), param_member_read(a, p, n). +param_member_tested(p, n) :- expr_child(_, _, "CONDITION", _, a), param_member_read(a, p, n). +param_member_tested(p, n) :- expr_root(_, "CONDITION", a), param_member_read(a, p, n). +param_member_read(a, p, n) :- expr_kind(_, k, _, a), (k = "PROPERTY_ACCESS" ; k = "OPTIONAL_ACCESS"), expr_name(_, n, a), + expr_child(_, a, "ACCESS_TARGET", _, t), expr_param(_, p, t). unresolved_value_callee(ce, "computed") :- call_site("client", "COMPUTED_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). unresolved_value_callee(ce, "dynamic_code") :- call_site("client", "DYNAMIC_CODE_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). +// `import { handler } from './hub'; handler()` or `hub.handler()` through a namespace, +// where hub does `export let handler = null` and reassigns it in a setter (#1708): an +// ES import is a LIVE binding, so the call runs whatever the setter stored last. The +// callee's value may be known (`null`) and still not be the one the call runs. An +// export the module never reassigns keeps its answer: its value is the one it holds. +unresolved_value_callee(ce, "imported_variable") :- value_callee_unresolved(ce, c), expr_binding(_, v, c), + var_import("client", imp, v), import_decl(_, _, _, bf, n, _, _, _, imp), import_binding_is_named(bf), n != "", + import_module(imp, mod), live_export_variable(mod, n). +unresolved_value_callee(ce, "imported_variable") :- call_site("client", ck, n, _, _, _, ce, _, _), + call_kind_is_member_form(ck), !call_kind_is_dynamic(ck), !call_resolved(ce), + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "module", mod), live_export_variable(mod, n). +// live_export_variable(Module, Name): the module exports, under Name, a variable some +// code assigns after its declaration — directly, or re-exported by name or by `export *`. +live_export_variable(mod, n) :- export_decl(_, n, _, _, _, "VARIABLE", v, _, mod, x), n != "", !export_overwritten(_, x), + variable_reassigned(v). +live_export_variable(mod, en) :- export_decl(_, en, ln, form, _, _, _, _, mod, x), form != "EXPORT_ALL", ln != "", + export_reexport(_, _, imp, x), import_module(imp, src), live_export_variable(src, ln). +live_export_variable(mod, n) :- export_decl(_, en, _, "EXPORT_ALL", _, _, _, _, mod, x), export_all_is_spread(en), + export_reexport(_, _, imp, x), import_module(imp, src), live_export_variable(src, n), n != "default". diff --git a/graph/javascript/engine/call-edge-generation/partial-dispatch.dl b/graph/javascript/engine/call-edge-generation/partial-dispatch.dl index 2b58cbc38..5af7f6c12 100644 --- a/graph/javascript/engine/call-edge-generation/partial-dispatch.dl +++ b/graph/javascript/engine/call-edge-generation/partial-dispatch.dl @@ -83,7 +83,38 @@ partial_dispatch_target(ce, m) :- call_site(_, "COMPUTED_CALL", _, _, _, _, ce, expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "obj", i), prop_value("obj", i, _, "func", m). +// ============================================================================ +// AN OPEN READ: THE SAME TABLE WITH NOTHING BESIDE IT +// +// const h = handlers[action.type]; h(state, p) a Redux-style reducer +// handlers[type](state, p) a command map +// cli.commands[argv[0]](argv) CLI dispatch +// +// No default merges here, so nothing was asserted wrongly: the site was a dead end +// (dynamic_terminal, or ambiguous_unknown through the const), and every entry of the +// table had no caller at all. The key is unknown; the table is not. The call runs one +// of its function values, which is what an array read with a computed index already +// says (resolution/arrays.dl). Emitted as edges for the reason above, and only where +// the value layer found NO target: a key whose value it could read keeps its answer. +// ============================================================================ +open_read(e, i) :- expr_kind(_, "ELEMENT_ACCESS", _, e), + expr_child(_, e, "COMPUTED_KEY", _, key), !expr_kind(_, "LITERAL", _, key), + expr_child(_, e, "ACCESS_TARGET", _, r), expr_value(r, "obj", i). + +open_dispatch_target(ce, m) :- call_target_count(ce, 0), expr_child(_, ce, "CALLEE", _, c), + open_read(c, i), prop_value("obj", i, _, "func", m). +open_dispatch_target(ce, m) :- call_target_count(ce, 0), expr_child(_, ce, "CALLEE", _, c), + expr_binding(_, v, c), var_init(_, _, e, v), open_read(e, i), prop_value("obj", i, _, "func", m). +open_dispatch_target(ce, m) :- call_target_count(ce, 0), call_site(_, "COMPUTED_CALL", _, _, _, _, ce, _, _), + expr_child(_, ce, "COMPUTED_KEY", _, key), !expr_kind(_, "LITERAL", _, key), + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "obj", i), prop_value("obj", i, _, "func", m). + +open_dispatch(ce) :- open_dispatch_target(ce, _). +open_dispatch_width(ce, n) :- open_dispatch(ce), n = count : { open_dispatch_target(ce, _) }. +partial_dispatch(ce) :- open_dispatch(ce). +partial_dispatch_target(ce, m) :- open_dispatch_target(ce, m). + // ── the edges, and the tier that stops asserting ─────────────────────────── call_chain_edge(ce, caller, "-", m, "client", "multi_inferred", kind) :- - partial_dispatch_target(ce, m), !expr_resolves_to_method(ce, m), + partial_dispatch_target(ce, m), !expr_resolves_to_method(ce, m), !call_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). diff --git a/graph/javascript/engine/projections/blocks.dl b/graph/javascript/engine/projections/blocks.dl index ba5f99353..7844ab61e 100644 --- a/graph/javascript/engine/projections/blocks.dl +++ b/graph/javascript/engine/projections/blocks.dl @@ -30,6 +30,11 @@ block_condition("lib", k, c, h) :- lib_js_block(k, _, _, _, _, _, _, c, _, _, _, block_child("client", p, k, idx, h) :- js_block(k, _, p, _, idx, _, _, _, _, _, _, _, _, _, _, _, h), p != "". block_child("lib", p, k, idx, h) :- lib_js_block(k, _, p, _, idx, _, _, _, _, _, _, _, _, _, _, _, h), p != "". +// ── block_owner(Kind, OwnerMethod, BlockHash) ────────────────────────────── +// The callable whose body holds the block: a write is conditional when a branching +// block of its OWN callable contains it (#1706). +block_owner(k, m, h) :- js_block(k, _, _, _, _, _, _, _, m, _, _, _, _, _, _, _, h), m != "". + // ── block_span(Prov, OwnerModule, StartLine, StartCol, EndLine, EndCol, BlockHash) ── // A block opens no scope of its own unless it declares something, so the span is // the one handle on "the expressions inside this block" (#701). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 1ce316c2a..15379c699 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -370,7 +370,13 @@ installed_function(val, m) :- expr_kind(_, "ASSIGNMENT", _, val), expr_child(_, // parser attributes a nested function's body to the nested function, so no // filtering by nesting is needed. Plus `@returns {T}`. return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), !expr_kind(_, "THIS", _, e), - !wrapped_result_return(e), expr_value(e, k, i). + !wrapped_result_return(e), !returns_own_class_new(m, e), expr_value(e, k, i). +// A wrapper closure's carrying return keeps every value that does NOT come from the +// parameter's call: `fn() || fallback`, `override || (cached = make())`, a variable +// that starts as a default (below). Not an `undefined` placeholder (`let result = +// undefined`): that is the cache before its fill, never what a call returns. +return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), wrapped_result_return(e), + carry_rest(e, x), expr_value(x, k, i), (k != "ambient" ; i != "undefined"). return_value(m, "inst", t) :- method_return_type(_, _, tr, m), type_ref_type(tr, t). // ── a WRAPPER's closure returns what its argument returns, per call site (#695) ── @@ -394,6 +400,9 @@ return_value(m, "inst", t) :- method_return_type(_, _, tr, m), type_ref_type(tr, wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_introduces(_, c, e). wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_binding(_, v, e), var_init(_, _, init, v), expr_introduces(_, c, init). +// ... or names a function declared inside the wrapper: `function inner() { ... } return inner`. +wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_binding(_, v, e), + function_binding_method(v, c), method_enclosing(c, h). param_call_kind("FUNCTION_CALL"). param_call_kind("FUNCTION_CALL_CALL"). param_call_kind("FUNCTION_CALL_APPLY"). @@ -413,14 +422,96 @@ var_carries_param_call(v, p) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator( // the wrapper's parameter at that position. wrapper_forwards(h, c, pos) :- wrapper_closure(h, c), param_decl(_, _, ppos, h, p), !param_is_rest(_, p), pos = to_number(ppos), expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). -wrapped_result_return(e) :- wrapper_closure(h, c), param_decl(_, _, _, h, p), +wrapped_result_return(e) :- wrapper_closure(h, c), param_decl(_, _, _, h, p), !param_is_rest(_, p), expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). +// ── the wrapper WRITES that parameter before the closure calls it (#1706) ── +// `fn = () => x`, `if (!fn) fn = dflt`, `fn = fn || dflt`, `fn ??= dflt` in the +// wrapper's own body: the closure calls what was written, so a call through the site +// also returns what each written function returns. The site's argument still counts +// unless the write REPLACES it: a plain `=` statement of the wrapper's body, outside +// every branch, whose value does not read the parameter. A write inside the closure +// (a memoizer's `fn = undefined` after the call) or in a nested function is not the +// wrapper's and changes nothing here. +wrapper_param_write(h, pos, p, a) :- wrapper_forwards(h, _, pos), param_decl(_, _, ppos, h, p), to_number(ppos) = pos, + expr_kind(_, "ASSIGNMENT", _, a), expr_owner(_, h, _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_param(_, p, tgt). +// The written value's operands, through the same transparent forms as the carry. +param_write_operand(a, v) :- wrapper_param_write(_, _, _, a), expr_child(_, a, "ASSIGNMENT_VALUE", _, v). +param_write_operand(a, o) :- param_write_operand(a, e), expr_kind(_, "CONDITIONAL", _, e), expr_child(_, e, r, _, o), r != "CONDITION". +param_write_operand(a, o) :- param_write_operand(a, e), value_passing_binary(e), expr_child(_, e, _, _, o). +param_write_operand(a, o) :- param_write_operand(a, e), expr_kind(_, "SEQUENCE", _, e), sequence_last(e, o). +param_write_operand(a, o) :- param_write_operand(a, e), expr_kind(_, "ASSIGNMENT", _, e), expr_child(_, e, "ASSIGNMENT_VALUE", _, o). +value_passing_binary(e) :- expr_kind(_, "BINARY", _, e), expr_operator(_, op, e), binary_op_passes_value(op). +param_write_leaf(a, o) :- param_write_operand(a, o), !expr_kind(_, "CONDITIONAL", _, o), !value_passing_binary(o), + !expr_kind(_, "SEQUENCE", _, o), !expr_kind(_, "ASSIGNMENT", _, o). +// A write that may leave the argument in place. +param_write_keeps_arg(a) :- wrapper_param_write(_, _, p, a), param_write_leaf(a, o), expr_param(_, p, o). +param_write_keeps_arg(a) :- wrapper_param_write(_, _, _, a), expr_operator(_, op, a), op != "=". +param_write_keeps_arg(a) :- wrapper_param_write(_, _, _, a), !expr_root(_, "EXPRESSION_STATEMENT", a). +param_write_keeps_arg(a) :- wrapper_param_write(h, _, _, a), expr_owner(_, h, mod, a), expr_span(_, l, c, _, _, a), + block_owner(k, h, b), block_kind_branches(k), block_span(_, mod, l1, c1, l2, c2, b), + (to_number(l) > to_number(l1) ; to_number(l) = to_number(l1), to_number(c) >= to_number(c1)), + (to_number(l) < to_number(l2) ; to_number(l) = to_number(l2), to_number(c) <= to_number(c2)). +block_kind_branches("IF"). +block_kind_branches("ELSE"). +block_kind_branches("FOR"). +block_kind_branches("FOR_IN"). +block_kind_branches("FOR_OF"). +block_kind_branches("WHILE"). +block_kind_branches("DO"). +block_kind_branches("SWITCH"). +block_kind_branches("SWITCH_CASE"). +block_kind_branches("TRY"). +block_kind_branches("CATCH"). +wrapper_param_replaced(h, pos) :- wrapper_param_write(h, pos, _, a), !param_write_keeps_arg(a). +wrapper_param_written(h, pos, o) :- wrapper_param_write(h, pos, p, a), param_write_leaf(a, o), !expr_param(_, p, o). +// Only the parameter's call is answered per site. carry_part walks the carrying +// return down the same operators as carries_param_call; carry_rest is each operand +// on the way that carries no parameter call (a fallback, an override, a variable's +// default initializer), which keeps its ordinary value. +carry_part(e, e) :- wrapped_result_return(e). +carry_part(e, c) :- carry_part(e, x), carries_param_call(x, _), carry_step(x, c). +carry_step(x, c) :- expr_kind(_, "CONDITIONAL", _, x), expr_child(_, x, r, _, c), r != "CONDITION". +carry_step(x, c) :- expr_kind(_, "BINARY", _, x), expr_operator(_, op, x), binary_op_passes_value(op), expr_child(_, x, _, _, c). +carry_step(x, c) :- expr_kind(_, "ASSIGNMENT", _, x), expr_child(_, x, "ASSIGNMENT_VALUE", _, c). +carry_step(x, c) :- expr_kind(_, "SEQUENCE", _, x), sequence_last(x, c). +carry_step(x, c) :- expr_binding(_, v, x), var_init(_, _, c, v). +carry_step(x, c) :- expr_binding(_, v, x), expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt), expr_child(_, a, "ASSIGNMENT_VALUE", _, c). +carry_rest(e, x) :- carry_part(e, x), !carries_param_call(x, _). +// What the carrying return holds read context-insensitively: every caller's argument +// merged (and nothing once the wrapper is over the fan cap). Only the fallbacks below +// use it, where no one site's argument says what the parameter is. +carried_value(c, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, c, _, e), wrapped_result_return(e), expr_value(e, k, i). // The site's own value, and a call through it. expr_value(s, "wrap", s) :- expr_kind(_, "CALL", _, s), expr_resolves_to_method(s, h), wrapper_forwards(h, _, _). expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), callee_value(ce, "wrap", s), + !call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), wrap_call_value(s, k, i). +// wrap_site(Site, Wrapper, Position); wrap_site_arg when the site passes that position +// itself (no spread at or before it), so its argument alone is the parameter. +wrap_site(s, h, pos) :- expr_kind(_, "CALL", _, s), expr_resolves_to_method(s, h), wrapper_forwards(h, _, pos). +wrap_site_spread(s, pos) :- wrapper_forwards(_, _, pos), call_arg(s, p2, a), p2 <= pos, expr_kind(_, "SPREAD", _, a). +wrap_site_arg(s, pos, arg) :- wrap_site(s, h, pos), call_arg(s, pos, arg), !wrap_site_spread(s, pos), !wrapper_param_replaced(h, pos). +// The argument is a function: what it returns. The argument is itself a wrapped +// getter (`memoize(memoize(f))`, `once(getAlpha)`): what a call through that site returns. +wrap_call_value(s, k, i) :- wrap_site_arg(s, _, arg), expr_value(arg, "func", g), return_value(g, k, i). +wrap_call_value(s, k, i) :- wrap_site_arg(s, _, arg), expr_value(arg, "wrap", s2), wrap_call_value(s2, k, i). +// No argument at that position: the parameter's default, `lazy(make = makeDefault)`. +wrap_call_value(s, k, i) :- wrap_site(s, h, pos), !call_arg(s, pos, _), !wrap_site_spread(s, pos), !wrapper_param_replaced(h, pos), + param_decl(_, _, ppos, h, p), to_number(ppos) = pos, param_default_text(_, txt, h, p), + expr_root(_, "PARAMETER_DEFAULT", d), expr_owner(_, h, _, d), expr_text(_, txt, d), + expr_value(d, "func", g), return_value(g, k, i). +// A spread argument: the site cannot say which function lands in the parameter. +wrap_call_value(s, k, i) :- wrap_site_spread(s, pos), wrap_site(s, h, pos), !wrapper_param_replaced(h, pos), wrapper_forwards(h, c, pos), carried_value(c, k, i). +// What the wrapper itself writes into the parameter (#1706) is called at every site, whatever it passes. +wrap_call_value(s, k, i) :- wrap_site(s, h, pos), wrapper_param_written(h, pos, o), expr_value(o, "func", g), return_value(g, k, i). +// The closure called inside the wrapper itself (`const c = () => fn(); c(); return c`) +// is not reached through a site: there the parameter is this invocation's, read merged. +expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method(ce, c), wrapper_forwards(h, c, _), !call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), - expr_resolves_to_method(s, h), wrapper_forwards(h, _, pos), call_arg(s, pos, arg), - expr_value(arg, "func", g), return_value(g, k, i). + expr_owner(_, m, _, ce), within_wrapper(m, h), carried_value(c, k, i). +within_wrapper(h, h) :- wrapper_forwards(h, _, _). +within_wrapper(m, h) :- method_enclosing(m, e), within_wrapper(e, h). // `return this` is POLYMORPHIC: a fluent `add()` declared on Node and called on an // AbstractBlock returns the AbstractBlock. So a `this`-return is not a value of the // method — it is the RECEIVER of each call, substituted at the site (below). A @@ -432,6 +523,26 @@ expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method(ce, expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method(ce, m), method_returns_this(m), call_site(_, ck, _, _, _, _, ce, _, _), !call_kind_is_member_form(ck), this_value(m, k, i). +// `return new this.constructor(...)` is polymorphic the same way: `clone()` declared on +// Base and called on a Sub builds a Sub. Read merged, the construction held every +// instantiated subclass of Base and each call on the clone fanned to all their +// overrides. So it is not a value of the method either; each site builds an instance of +// its own receiver's class. (`new this()` in a static is not read this way: the compiler +// types it as the declaring class, and the oracle would score the receiver's as WRONG.) +// Returned directly, or through a `const` it initialises (`const c = new +// this.constructor(); c.x = …; return c`, the everyday clone), which nothing rebinds. +own_class_new(m, e) :- expr_kind(_, "NEW", _, e), call_site(_, "CONSTRUCTOR_CALL", "constructor", "SYNTACTIC", _, _, e, _, _), + expr_child(_, e, "RECEIVER", _, r), expr_kind(_, "THIS", _, r), expr_owner(_, m, _, r). +returns_own_class_new(m, e) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), own_class_new(m, e). +returns_own_class_new(m, e) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), expr_kind(_, "IDENTIFIER", _, e), + expr_binding(_, v, e), var_decl(_, _, "CONST_BLOCK_TDZ", _, _, _, v), var_binding_form(_, "IDENTIFIER", v), + var_init(_, _, n, v), own_class_new(m, n). +expr_value(ce, "inst", t) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method(ce, m), returns_own_class_new(m, _), + call_site(_, ck, _, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "inst", t). +expr_value(ce, "inst", t) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method(ce, m), returns_own_class_new(m, _), + call_site(_, ck, _, _, _, _, ce, _, _), !call_kind_is_member_form(ck), + this_value(m, "inst", t). // ── prop_value(K, I, Name, K2, I2) — reading a property off a value ──────── // Object literal: `{ a: v }` (key + value children at the same child index), diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 32ddecf4c..ed873176a 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -72,6 +72,22 @@ .decl unresolved_covered(c0:symbol) .decl value_callee_open(c0:symbol, c1:symbol) .decl unresolved_value_callee(c0:symbol, c1:symbol) +.decl callee_builds_code(c0:symbol) +.decl value_callee_unresolved(c0:symbol, c1:symbol) +.decl callee_platform_function(c0:symbol) +.decl param_member_tested(c0:symbol, c1:symbol) +.decl param_member_read(c0:symbol, c1:symbol, c2:symbol) +.decl this_field_named(c0:symbol, c1:symbol, c2:symbol) +.decl field_call(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl instance_or_class_value(c0:symbol, c1:symbol, c2:symbol) +.decl callee_holds_function(c0:symbol) +.decl field_assignment(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl field_open_fallback(c0:symbol, c1:symbol, c2:symbol) +.decl module_variable_from_call(c0:symbol) +.decl variable_reassigned(c0:symbol) +.decl call_passes_function(c0:symbol) +.decl live_export_variable(c0:symbol, c1:symbol) +.decl this_type_open(c0:symbol) // ── containment/ownership.dl ── .decl call_caller_method(c0:symbol, c1:symbol) @@ -277,6 +293,11 @@ .decl swallowed_read(c0:symbol, c1:symbol) .decl partial_dispatch(c0:symbol) .decl partial_dispatch_target(c0:symbol, c1:symbol) +// the same read with no default beside it, called: one of the literal's functions +.decl open_read(c0:symbol, c1:symbol) +.decl open_dispatch_target(c0:symbol, c1:symbol) +.decl open_dispatch(c0:symbol) +.decl open_dispatch_width(c0:symbol, c1:number) .decl binary_op_passes_value(c0:symbol) .decl var_value(c0:symbol, c1:symbol, c2:symbol) .decl assignment_op_writes_value(c0:symbol) @@ -286,6 +307,7 @@ .decl instanceof_test(c0:symbol, c1:symbol) .decl block_child(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl block_span(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:symbol, c6:symbol) +.decl block_owner(c0:symbol, c1:symbol, c2:symbol) .decl typeof_guard(c0:symbol, c1:symbol) .decl typeof_eq_op(c0:symbol) .decl typeof_primitive(c0:symbol) @@ -309,6 +331,23 @@ .decl var_carries_param_call(c0:symbol, c1:symbol) .decl wrapper_forwards(c0:symbol, c1:symbol, c2:number) .decl wrapped_result_return(c0:symbol) +.decl wrapper_param_write(c0:symbol, c1:number, c2:symbol, c3:symbol) +.decl param_write_operand(c0:symbol, c1:symbol) +.decl value_passing_binary(c0:symbol) +.decl param_write_leaf(c0:symbol, c1:symbol) +.decl param_write_keeps_arg(c0:symbol) +.decl block_kind_branches(c0:symbol) +.decl wrapper_param_replaced(c0:symbol, c1:number) +.decl wrapper_param_written(c0:symbol, c1:number, c2:symbol) +.decl carry_part(c0:symbol, c1:symbol) +.decl carry_step(c0:symbol, c1:symbol) +.decl carry_rest(c0:symbol, c1:symbol) +.decl carried_value(c0:symbol, c1:symbol, c2:symbol) +.decl wrap_site(c0:symbol, c1:symbol, c2:number) +.decl wrap_site_spread(c0:symbol, c1:number) +.decl wrap_site_arg(c0:symbol, c1:number, c2:symbol) +.decl wrap_call_value(c0:symbol, c1:symbol, c2:symbol) +.decl within_wrapper(c0:symbol, c1:symbol) .decl prop_value(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl member_write(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl prototype_object(c0:symbol, c1:symbol) @@ -358,6 +397,8 @@ .decl var_regime_is_block(c0:symbol) .decl call_ambient_alternative(c0:symbol) .decl method_returns_this(c0:symbol) +.decl returns_own_class_new(c0:symbol, c1:symbol) +.decl own_class_new(c0:symbol, c1:symbol) .decl module_base_path(c0:symbol, c1:symbol, c2:symbol) // ── resolution/arrays.dl: a class extending Map / Set (#619) ── .decl coll_ctor(c0:symbol, c1:symbol) diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 79c797220..4d47f08cc 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -600,6 +600,22 @@ if [ -n "$PACKAGED" ]; then elif [ -x "$BIN" ]; then echo "▶ reusing cached binary" elif command -v souffle >/dev/null 2>&1; then + # ONE COMPILE PER ENGINE ID. Concurrent runs that miss the cache together (a suite's + # concurrent cases, several agents on one machine) each compiled the same engine: a + # multi-GB c++ per run, enough of them at once to exhaust memory. The first takes the + # lock and compiles; the others wait, then reuse its binary. A lock older than 30 min + # is a dead compile's (killed, out of memory) and is taken over. + COMPILE_LOCK="$BIN.lock"; _waited=0 + until mkdir "$COMPILE_LOCK" 2>/dev/null; do + if [ -n "$(find "$COMPILE_LOCK" -maxdepth 0 -mmin +30 2>/dev/null)" ]; then rmdir "$COMPILE_LOCK" 2>/dev/null || true; continue; fi + [ "$_waited" = 1 ] || echo "▶ another run is compiling this engine; waiting for it..." + _waited=1; sleep 3 + done + trap 'rmdir "$COMPILE_LOCK" 2>/dev/null || true' EXIT +fi +if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then + echo "▶ reusing the binary another run compiled" +elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then echo "▶ compiling souffle program (cache miss)..." INNER="$(find_souffle_include)" # Assert the HEADER, not the directory: `[ -d ]` is the test #216 established cannot tell @@ -636,7 +652,9 @@ elif command -v souffle >/dev/null 2>&1; then exit 1 fi mv -f "$BIN.tmp.$$" "$BIN" -else +fi +if [ -n "${COMPILE_LOCK:-}" ]; then rmdir "$COMPILE_LOCK" 2>/dev/null || true; trap - EXIT +elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 echo " • install souffle $SOUFFLE_VERSION to compile locally (macOS: brew install souffle; Ubuntu: the .deb from souffle-lang/souffle releases)." >&2 diff --git a/graph/test/javascript/cases/61-dispatch-table-computed-key/src/main.js b/graph/test/javascript/cases/61-dispatch-table-computed-key/src/main.js index 05b13316e..c06bb24d7 100644 --- a/graph/test/javascript/cases/61-dispatch-table-computed-key/src/main.js +++ b/graph/test/javascript/cases/61-dispatch-table-computed-key/src/main.js @@ -24,8 +24,8 @@ function viaTernary(prop, a, b) { } // THE CONSTRUCT, form 3: no default at all, and the read is called directly. Nothing -// merges here, so the site was already honest; it is listed to show the widened value -// set reaches the call rather than only the variable. +// merges here; the call reaches one of the table's functions (case 68 covers this +// open read in its own right). function viaDirect(prop, a, b) { return table[prop](a, b); } diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/declared.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/declared.js new file mode 100644 index 000000000..79e38f490 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/declared.js @@ -0,0 +1,48 @@ +// The closure is a function DECLARED inside the wrapper and returned by name, +// with enough callers to put the wrapper over the parameter fan cap. +const memoize = require("./memoize"); +const AlphaPlugin = require("./alpha"); +const BetaPlugin = require("./beta"); + +function onceDecl(fn) { + let r; + function inner() { if (!r) r = fn(); return r; } + return inner; +} +// CONTROL: a declared closure that calls its parameter but returns something else. +class Mark { markOnly() { return 4; } } +function tapDecl(g) { + function inner() { g(); return new Mark(); } + return inner; +} +// CONTROL: a function declared OUTSIDE the wrapper, returned by name. +function shared() { return new Mark(); } +function pickShared(fn) { fn(); return shared; } + +const declAlpha = onceDecl(() => new AlphaPlugin()); +const declBeta = onceDecl(() => new BetaPlugin()); +const getAlpha = memoize(() => new AlphaPlugin()); +const declOfGetter = onceDecl(getAlpha); +const tappedDecl = tapDecl(() => new AlphaPlugin()); +const picked = pickShared(() => new BetaPlugin()); + +function declared() { + declAlpha().run(); + declBeta().run(); + declOfGetter().run(); + tappedDecl().markOnly(); + picked().markOnly(); +} + +const d01 = onceDecl(() => 1); const d02 = onceDecl(() => 2); const d03 = onceDecl(() => 3); +const d04 = onceDecl(() => 4); const d05 = onceDecl(() => 5); const d06 = onceDecl(() => 6); +const d07 = onceDecl(() => 7); const d08 = onceDecl(() => 8); const d09 = onceDecl(() => 9); +const d10 = onceDecl(() => 10); const d11 = onceDecl(() => 11); const d12 = onceDecl(() => 12); +const d13 = onceDecl(() => 13); const d14 = onceDecl(() => 14); const d15 = onceDecl(() => 15); +const d16 = onceDecl(() => 16); const d17 = onceDecl(() => 17); const d18 = onceDecl(() => 18); +const d19 = onceDecl(() => 19); const d20 = onceDecl(() => 20); const d21 = onceDecl(() => 21); +const d22 = onceDecl(() => 22); const d23 = onceDecl(() => 23); const d24 = onceDecl(() => 24); +const d25 = onceDecl(() => 25); const d26 = onceDecl(() => 26); const d27 = onceDecl(() => 27); + +module.exports = { declared, d01, d02, d03, d04, d05, d06, d07, d08, d09, d10, d11, d12, d13, + d14, d15, d16, d17, d18, d19, d20, d21, d22, d23, d24, d25, d26, d27 }; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/shapes.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/shapes.js new file mode 100644 index 000000000..dd2f4413f --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/shapes.js @@ -0,0 +1,60 @@ +// Wrapper shapes whose closure returns MORE than the parameter's call, or whose +// site does not name the parameter itself. Each call must keep what it returned +// before the per-site answer: the fallback, the override, the default. +const memoize = require("./memoize"); +const AlphaPlugin = require("./alpha"); +const BetaPlugin = require("./beta"); + +class Fallback { fallbackOnly() { return 0; } } +class Mock { mockOnly() { return 1; } } +class Defaulted { defaultOnly() { return 2; } } + +// `fn() || fallback`: what the argument returns, or the fallback. +const fallback = new Fallback(); +function withFallback(fn) { + return () => fn() || fallback; +} +// An override that wins over the built value. +let override = null; +function setOverride(o) { override = o; } +function lazyOr(make) { + let cached; + return () => override || (cached = cached || make()); +} +// A result variable that starts as a default object. +const defaults = { defaultsOnly() { return 3; } }; +function memoDefault(fn) { + let result = defaults; + let done = false; + return () => { if (!done) { done = true; result = fn() || result; } return result; }; +} +// A default parameter, a spread argument, a closure called inside its own wrapper. +const makeDefault = () => new Defaulted(); +function lazy(make = makeDefault) { return () => make(); } +function eager(fn) { const c = () => fn(); c().run(); return c; } + +const getFb = withFallback(() => new AlphaPlugin()); +setOverride(new Mock()); +const getOr = lazyOr(() => new BetaPlugin()); +const getD = memoDefault(() => new AlphaPlugin()); +const getDefault = lazy(); +const args = [() => new BetaPlugin()]; +const getSpread = lazy(...args); +const warm = eager(() => new AlphaPlugin()); +// A wrapped getter wrapped again: the inner site's argument decides. +const getNested = memoize(memoize(() => new BetaPlugin())); + +function shapes() { + getFb().run(); + getFb().fallbackOnly(); + getOr().run(); + getOr().mockOnly(); + getD().run(); + getD().defaultsOnly(); + getDefault().defaultOnly(); + getSpread().run(); + warm().run(); + getNested().run(); +} + +module.exports = { shapes }; diff --git a/graph/test/javascript/cases/65-stored-value-callee/src/stored.js b/graph/test/javascript/cases/65-stored-value-callee/src/stored.js new file mode 100644 index 000000000..8bcc315c4 --- /dev/null +++ b/graph/test/javascript/cases/65-stored-value-callee/src/stored.js @@ -0,0 +1,28 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +let handler = null; +function setHandler(h) { handler = h; } +function fire() { return handler(); } +class Emitter { + constructor(cb) { this.cb = cb; } + emit() { return this.cb(); } + own() { return 1; } + callsOwn() { return this.own(); } +} +function optsTypeof(opts) { if (typeof opts.onReady === 'function') return opts.onReady(); return null; } +function optsAnd(opts) { return opts.onDone && opts.onDone(); } +function optsIf(opts) { if (opts.onEnd) { opts.onEnd(); } } +function optsOptional(opts) { return opts.onStart?.(); } +function condInvoke(a, b, c) { return (c ? a : b)(); } +function seqInvoke(cb) { return (0, cb)(); } +function newFn(s) { return new Function(s)(); } +const helper = function () { return alpha(); }; +function viaHelper() { return helper(); } +function joins() { return path.join('a', 'b'); } +function literalTrim() { return 'abc'.trim(); } +function pure() { return 1 + 2; } +function optsPlain(opts) { return opts.onReady(); } +function respond(req, res) { return res.status(200).send(req.query.q); } +module.exports = { alpha, setHandler, fire, Emitter, optsTypeof, optsAnd, optsIf, optsOptional, condInvoke, seqInvoke, newFn, + viaHelper, joins, literalTrim, pure, optsPlain, respond }; diff --git a/graph/test/javascript/cases/65-wrapper-param-reassigned/src/app.js b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/app.js new file mode 100644 index 000000000..fceb392b0 --- /dev/null +++ b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/app.js @@ -0,0 +1,21 @@ +const { A, swapped, lazyGuard, lazyOrDefault, lazyNullish, plain, otherWritten } = require("./wrap"); + +function replaced() { + const sw = swapped(() => new A()); + sw().replacedOnly(); + sw().aOnly(); + lazyGuard(undefined)().replacedOnly(); + lazyGuard(() => new A())().aOnly(); + lazyOrDefault()().dfltOnly(); + lazyOrDefault(() => new A())().aOnly(); + lazyNullish()().dfltOnly(); +} + +function controls() { + plain(() => new A())().aOnly(); + plain(() => new A())().replacedOnly(); + otherWritten(() => new A(), null)().aOnly(); + otherWritten(() => new A(), null)().replacedOnly(); +} + +module.exports = { replaced, controls }; diff --git a/graph/test/javascript/cases/65-wrapper-param-reassigned/src/many.js b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/many.js new file mode 100644 index 000000000..d1e04e65a --- /dev/null +++ b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/many.js @@ -0,0 +1,14 @@ +// Enough callers to put `swapped` over the parameter fan cap. +const { A, swapped } = require("./wrap"); +const s01 = swapped(() => new A()); const s02 = swapped(() => new A()); const s03 = swapped(() => new A()); +const s04 = swapped(() => new A()); const s05 = swapped(() => new A()); const s06 = swapped(() => new A()); +const s07 = swapped(() => new A()); const s08 = swapped(() => new A()); const s09 = swapped(() => new A()); +const s10 = swapped(() => new A()); const s11 = swapped(() => new A()); const s12 = swapped(() => new A()); +const s13 = swapped(() => new A()); const s14 = swapped(() => new A()); const s15 = swapped(() => new A()); +const s16 = swapped(() => new A()); const s17 = swapped(() => new A()); const s18 = swapped(() => new A()); +const s19 = swapped(() => new A()); const s20 = swapped(() => new A()); const s21 = swapped(() => new A()); +function capped() { + s21().replacedOnly(); + s21().aOnly(); +} +module.exports = { capped, all: [s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12, s13, s14, s15, s16, s17, s18, s19, s20] }; diff --git a/graph/test/javascript/cases/65-wrapper-param-reassigned/src/wrap.js b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/wrap.js new file mode 100644 index 000000000..668ebdb3c --- /dev/null +++ b/graph/test/javascript/cases/65-wrapper-param-reassigned/src/wrap.js @@ -0,0 +1,35 @@ +// Wrappers that WRITE their parameter before the returned closure calls it. +class A { aOnly() { return 1; } } +class Replaced { replacedOnly() { return 2; } } +class Dflt { dfltOnly() { return 3; } } + +// Unconditional replacement: the site's argument never reaches the closure. +function swapped(fn) { + fn = () => new Replaced(); + return () => fn(); +} +// Conditional replacement: the site's argument, or the replacement. +function lazyGuard(fn) { + if (typeof fn !== "function") fn = () => new Replaced(); + return () => fn(); +} +// A default through `||`: the site's argument, or the default. +function lazyOrDefault(fn) { + fn = fn || (() => new Dflt()); + return () => fn(); +} +// A default through `??=`. +function lazyNullish(fn) { + fn ??= () => new Dflt(); + return () => fn(); +} +// CONTROL: no write at all — the site's argument alone. +function plain(fn) { + return () => fn(); +} +// CONTROL: another parameter is written, not the one the closure calls. +function otherWritten(fn, alt) { + alt = () => new Replaced(); + return () => fn(); +} +module.exports = { A, Replaced, Dflt, swapped, lazyGuard, lazyOrDefault, lazyNullish, plain, otherWritten }; diff --git a/graph/test/javascript/cases/66-stored-callee-shapes/src/shapes.js b/graph/test/javascript/cases/66-stored-callee-shapes/src/shapes.js new file mode 100644 index 000000000..753496438 --- /dev/null +++ b/graph/test/javascript/cases/66-stored-callee-shapes/src/shapes.js @@ -0,0 +1,47 @@ +'use strict'; +const debug = require('debug'); +function alpha() { return 'a'; } +class FieldNull { + cb = null; + set(f) { this.cb = f; } + run() { return this.cb(); } +} +class CtorNull { + constructor() { this.cb = null; } + set(f) { this.cb = f; } + run() { return this.cb(); } +} +class Fallback { + constructor(cb) { this.cb = cb || function () {}; } + run() { return this.cb(); } +} +class Static { + static set(f) { this.cb = f; } + static run() { return this.cb(); } +} +class StaticField { + static cb = null; + static run() { return this.cb(); } +} +class Alias { + constructor(cb) { this.cb = cb; } + run() { const self = this; return self.cb(); } +} +class Maker { + constructor(C) { this.Ctor = C; } + make() { return new this.Ctor(); } +} +const log = debug('app'); +function logs() { log('x'); return 1; } +const tag = require('util').format; +function tags() { return tag('%s', 'x'); } +const wrapped = debug(alpha); +function callsWrapped() { return wrapped(); } +// controls: none of these is a value callee +const EventEmitter = require('events'); +class Bus extends EventEmitter { go() { return this.emit('x'); } } +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 }; diff --git a/graph/test/javascript/cases/67-imported-live-variable/src/barrel.js b/graph/test/javascript/cases/67-imported-live-variable/src/barrel.js new file mode 100644 index 000000000..58cde756f --- /dev/null +++ b/graph/test/javascript/cases/67-imported-live-variable/src/barrel.js @@ -0,0 +1,2 @@ +export { handler as relayed } from './hub.js'; +export * from './hub.js'; diff --git a/graph/test/javascript/cases/67-imported-live-variable/src/hub.js b/graph/test/javascript/cases/67-imported-live-variable/src/hub.js new file mode 100644 index 000000000..d625e0668 --- /dev/null +++ b/graph/test/javascript/cases/67-imported-live-variable/src/hub.js @@ -0,0 +1,8 @@ +export function alpha() { return 'a'; } +export let handler = null; +export function setHandler(h) { handler = h; } +let hook = null; +export { hook }; +export function setHook(h) { hook = h; } +export let fixed = null; +export const ready = () => 'r'; diff --git a/graph/test/javascript/cases/67-imported-live-variable/src/main.js b/graph/test/javascript/cases/67-imported-live-variable/src/main.js new file mode 100644 index 000000000..608b417f2 --- /dev/null +++ b/graph/test/javascript/cases/67-imported-live-variable/src/main.js @@ -0,0 +1,13 @@ +import { handler, hook, fixed, ready } from './hub.js'; +import * as hub from './hub.js'; +import { relayed } from './barrel.js'; +import * as barrel from './barrel.js'; +export function fireImported() { return handler(); } +export function fireNs() { return hub.handler(); } +export function fireSpecifier() { return hook(); } +export function fireRelayed() { return relayed(); } +export function fireBarrelNs() { return barrel.handler(); } +// controls: an export nothing reassigns, and one holding a function +export function fireFixed() { return fixed(); } +export function fireReady() { return ready(); } +export function fireNsFixed() { return hub.fixed(); } diff --git a/graph/test/javascript/cases/67-imported-live-variable/src/package.json b/graph/test/javascript/cases/67-imported-live-variable/src/package.json new file mode 100644 index 000000000..4c1eeabfc --- /dev/null +++ b/graph/test/javascript/cases/67-imported-live-variable/src/package.json @@ -0,0 +1 @@ +{ "name": "imported-live-variable", "type": "module" } diff --git a/graph/test/javascript/cases/68-dispatch-map-open-key/src/handlers.js b/graph/test/javascript/cases/68-dispatch-map-open-key/src/handlers.js new file mode 100644 index 000000000..151254842 --- /dev/null +++ b/graph/test/javascript/cases/68-dispatch-map-open-key/src/handlers.js @@ -0,0 +1,9 @@ +// A dispatch table exported as an object literal: reducers, command maps and CLI +// dispatch are written this way, and the key that picks the entry is data. +function addItem(state, p) { return [...state, p]; } +function removeItem(state, p) { return state.filter((x) => x !== p); } +export const handlers = { ADD: addItem, REMOVE: removeItem, CLEAR: () => [], LIMIT: 10 }; + +function runBuild(argv) { return argv; } +function runServe(argv) { return argv; } +export const cli = { commands: { build: runBuild, serve: runServe } }; diff --git a/graph/test/javascript/cases/68-dispatch-map-open-key/src/store.js b/graph/test/javascript/cases/68-dispatch-map-open-key/src/store.js new file mode 100644 index 000000000..20b5cca65 --- /dev/null +++ b/graph/test/javascript/cases/68-dispatch-map-open-key/src/store.js @@ -0,0 +1,47 @@ +// A call through an object-literal table whose key is computed at run time, with NO +// default beside it. The engine cannot know which entry the key selects, but it does +// know the table: the call reaches one of its function values. Before, the site was a +// dead end (dynamic_terminal / ambiguous_unknown) and removeItem had no caller at all. +import { handlers, cli } from './handlers.js'; + +// THE CONSTRUCT, form 1: the read assigned to a const, then called (a Redux-style reducer). +export function reducer(state, action) { + const h = handlers[action.type]; + return h ? h(state, action.payload) : state; +} + +// THE CONSTRUCT, form 2: the read called directly. +export function dispatch(state, type, p) { + return handlers[type](state, p); +} + +// THE CONSTRUCT, form 3: a nested table reached through a property chain (CLI dispatch). +export function main(argv) { + return cli.commands[argv[0]](argv); +} + +// CONTROL 1: a dotted read names the exact entry and must stay a single known edge. +export function add(state, p) { return handlers.ADD(state, p); } + +// CONTROL 2: a literal key names the exact entry too. +export function remove(state, p) { return handlers['REMOVE'](state, p); } + +// CONTROL 3: a key held in a const the value layer reads keeps its own answer; the +// open-read rule only speaks where nothing else found a target. +const KEY = 'ADD'; +export function addByConst(state, p) { return handlers[KEY](state, p); } + +// CONTROL 4: a computed read on a class instance is not an object literal; it stays +// the declared dynamic_terminal it was. +class Router { + get(x) { return x; } + post(x) { return x; } + route(verb, x) { return this[verb](x); } +} +export const router = new Router(); + +// CONTROL 5: an array read with a computed index is excluded by kind. +const steps = [addItem2, removeItem2]; +function addItem2(s) { return s; } +function removeItem2(s) { return s; } +export function step(i, s) { return steps[i](s); } diff --git a/graph/test/javascript/cases/68-instance-member-callee/src/index.js b/graph/test/javascript/cases/68-instance-member-callee/src/index.js new file mode 100644 index 000000000..e749e0504 --- /dev/null +++ b/graph/test/javascript/cases/68-instance-member-callee/src/index.js @@ -0,0 +1,23 @@ +// `this.x()` where no class declares `x`: the function is stored on the instance +// from outside, so the site is a value callee (#1709). +function alpha() { return 'a'; } +class External { run() { return this.hook(); } } +function wire(e, fn) { e.hook = fn; } +class Assigned { + constructor(opts) { Object.assign(this, opts); } + run() { return this.onReady(); } +} +class Stored { + constructor(x) { this.x = x; } + run() { return this.x(); } +} +// controls: a declared method resolves; a base the graph cannot see may declare it +class Declared { + own() { return 1; } + run() { return this.own(); } +} +const Base = require('some-unstaged-base'); +class Sub extends Base { run() { return this.emit('x'); } } +// control: an Object.prototype member is the platform's, not a stored value +class Proto { run(k) { return this.hasOwnProperty(k); } } +module.exports = { alpha, External, wire, Assigned, Stored, Declared, Sub, Proto }; diff --git a/graph/test/javascript/cases/69-this-constructor-per-site/src/main.js b/graph/test/javascript/cases/69-this-constructor-per-site/src/main.js new file mode 100644 index 000000000..cc3033fe2 --- /dev/null +++ b/graph/test/javascript/cases/69-this-constructor-per-site/src/main.js @@ -0,0 +1,43 @@ +'use strict'; +// `new this.constructor()` builds the RECEIVER's class at each site, not every +// subclass of the class that declares it. +class Base { + clone() { return new this.constructor(); } + copy() { return new Base(); } + run() { return 'base'; } +} +class Sub extends Base { run() { return 'sub'; } } +class Other extends Base { run() { return 'other'; } } + +// The same shape on a constructor function and its prototype. +function Shape() {} +Shape.prototype.dup = function () { return new this.constructor(); }; +Shape.prototype.area = function () { return 0; }; +function Circle() { Shape.call(this); } +Circle.prototype = Object.create(Shape.prototype); +Circle.prototype.constructor = Circle; +Circle.prototype.area = function () { return 3; }; + +// The everyday clone: built into a `const`, filled in, returned. +class Query { + clone() { const q = new this.constructor(); q.opts = this.opts; return q; } + // Control: a `let` may be rebound before the return, so it keeps its merged value. + fork() { let q = new this.constructor(); return q; } + exec() { return 'query'; } +} +class Select extends Query { exec() { return 'select'; } } + +function main() { + new Select().clone().exec(); + new Query().clone().exec(); + new Select().fork().exec(); + new Sub().clone().run(); + new Other().clone().run(); + new Sub().clone().clone().run(); + new Base().clone().run(); + // Control: a fixed `new Base()` stays Base whatever the receiver. + new Sub().copy().run(); + new Circle().dup().area(); + new Shape().dup().area(); +} +main(); diff --git a/graph/test/javascript/cases/69-this-constructor-per-site/src/package.json b/graph/test/javascript/cases/69-this-constructor-per-site/src/package.json new file mode 100644 index 000000000..44f892347 --- /dev/null +++ b/graph/test/javascript/cases/69-this-constructor-per-site/src/package.json @@ -0,0 +1 @@ +{ "name": "this-constructor-per-site" } diff --git a/graph/test/javascript/expected/04-parameters-and-fields.diag b/graph/test/javascript/expected/04-parameters-and-fields.diag index 575e18922..91cbbcbe7 100644 --- a/graph/test/javascript/expected/04-parameters-and-fields.diag +++ b/graph/test/javascript/expected/04-parameters-and-fields.diag @@ -1,2 +1,3 @@ package_entry probe4 . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved a.js:8:11 METHOD_CALL typed member_absent +value_callee a.js:8:11 this.typed field diff --git a/graph/test/javascript/expected/07-esm-class-hierarchy.edges b/graph/test/javascript/expected/07-esm-class-hierarchy.edges index 7e2895c72..feab8244d 100644 --- a/graph/test/javascript/expected/07-esm-class-hierarchy.edges +++ b/graph/test/javascript/expected/07-esm-class-hierarchy.edges @@ -10,8 +10,7 @@ Component.js:7:15 CONSTRUCTOR_CALL AbstractBlock -> known_edge AbstractBlock. Component.js:7:5 METHOD_CALL block.add -> known_edge Node.js:8:3 add Component.js:7:5 METHOD_CALL block.add(new AbstractBlock(this)).walk -> known_edge AbstractBlock.js:4:3 walk Component.js:8:18 METHOD_CALL block.clone -> known_edge Node.js:9:3 clone -Component.js:9:5 METHOD_CALL copy.walk -> multi_inferred AbstractBlock.js:4:3 walk -Component.js:9:5 METHOD_CALL copy.walk -> multi_inferred Node.js:7:3 walk +Component.js:9:5 METHOD_CALL copy.walk -> known_edge AbstractBlock.js:4:3 walk Node.js:7:12 METHOD_CALL this.component.warn -> known_edge Component.js:3:3 warn Node.js:7:39 METHOD_CALL this.children.forEach -> ambient_terminal - Node.js:7:39 METHOD_CALL this.children.forEach -> callback_registered Node.js:7:61 diff --git a/graph/test/javascript/expected/37-this-constructor.edges b/graph/test/javascript/expected/37-this-constructor.edges index f6cbd48d9..129a00d4f 100644 --- a/graph/test/javascript/expected/37-this-constructor.edges +++ b/graph/test/javascript/expected/37-this-constructor.edges @@ -1,14 +1,12 @@ main.js:11:13 CONSTRUCTOR_CALL Sub -> implicit_constructor - main.js:12:3 METHOD_CALL s.clone -> known_edge main.js:3:3 clone -main.js:12:3 METHOD_CALL s.clone().run -> multi_inferred main.js:5:3 run -main.js:12:3 METHOD_CALL s.clone().run -> multi_inferred main.js:8:26 run +main.js:12:3 METHOD_CALL s.clone().run -> known_edge main.js:8:26 run main.js:13:3 METHOD_CALL Sub.build -> known_edge main.js:6:3 build main.js:13:3 METHOD_CALL Sub.build().run -> multi_inferred main.js:5:3 run main.js:13:3 METHOD_CALL Sub.build().run -> multi_inferred main.js:8:26 run main.js:14:3 CONSTRUCTOR_CALL Base -> implicit_constructor - main.js:14:3 METHOD_CALL new Base().make -> known_edge main.js:4:3 make -main.js:14:3 METHOD_CALL new Base().make().run -> multi_inferred main.js:5:3 run -main.js:14:3 METHOD_CALL new Base().make().run -> multi_inferred main.js:8:26 run +main.js:14:3 METHOD_CALL new Base().make().run -> known_edge main.js:5:3 run main.js:16:1 FUNCTION_CALL main -> known_edge main.js:10:1 main main.js:3:20 CONSTRUCTOR_CALL this.constructor -> implicit_constructor - main.js:4:19 CONSTRUCTOR_CALL this.constructor -> implicit_constructor - diff --git a/graph/test/javascript/expected/40-object-keys-strings.edges b/graph/test/javascript/expected/40-object-keys-strings.edges index 9df9ddc9e..8b4be9e38 100644 --- a/graph/test/javascript/expected/40-object-keys-strings.edges +++ b/graph/test/javascript/expected/40-object-keys-strings.edges @@ -25,5 +25,6 @@ main.js:33:27 FUNCTION_CALL fn -> multi_inferred main.js:4:1 beta main.js:34:26 METHOD_CALL i.padStart -> ambient_terminal - main.js:34:41 COMPUTED_CALL fns[i] -> multi_inferred main.js:3:1 alpha main.js:34:41 COMPUTED_CALL fns[i] -> multi_inferred main.js:4:1 beta -main.js:35:34 COMPUTED_CALL handlers[name] -> dynamic_terminal - +main.js:35:34 COMPUTED_CALL handlers[name] -> multi_inferred main.js:3:1 alpha +main.js:35:34 COMPUTED_CALL handlers[name] -> multi_inferred main.js:4:1 beta main.js:9:21 METHOD_CALL Object.keys -> ambient_terminal - diff --git a/graph/test/javascript/expected/49-chained-member-assignment.diag b/graph/test/javascript/expected/49-chained-member-assignment.diag index d6277ea1a..351ec34a0 100644 --- a/graph/test/javascript/expected/49-chained-member-assignment.diag +++ b/graph/test/javascript/expected/49-chained-member-assignment.diag @@ -1,3 +1,4 @@ package_entry 49-chained-member-assignment . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved core.js:14:19 METHOD_CALL create no_target unresolved main.js:14:3 METHOD_CALL tag2 member_absent +value_callee main.js:14:3 Model.tag2 field diff --git a/graph/test/javascript/expected/61-dispatch-table-computed-key.edges b/graph/test/javascript/expected/61-dispatch-table-computed-key.edges index 018a2a377..96782ca78 100644 --- a/graph/test/javascript/expected/61-dispatch-table-computed-key.edges +++ b/graph/test/javascript/expected/61-dispatch-table-computed-key.edges @@ -6,7 +6,8 @@ main.js:22:18 FUNCTION_CALL has -> known_edge main.js:10:13 main.js:23:10 FUNCTION_CALL chosen -> multi_inferred main.js:7:1 fromA main.js:23:10 FUNCTION_CALL chosen -> multi_inferred main.js:8:1 fromB main.js:23:10 FUNCTION_CALL chosen -> multi_inferred main.js:9:1 deep -main.js:30:10 COMPUTED_CALL table[prop] -> dynamic_terminal - +main.js:30:10 COMPUTED_CALL table[prop] -> multi_inferred main.js:7:1 fromA +main.js:30:10 COMPUTED_CALL table[prop] -> multi_inferred main.js:8:1 fromB main.js:38:10 FUNCTION_CALL chosen -> multi_inferred main.js:7:1 fromA main.js:38:10 FUNCTION_CALL chosen -> multi_inferred main.js:9:1 deep main.js:44:10 FUNCTION_CALL chosen -> multi_inferred main.js:8:1 fromB diff --git a/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag b/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag index edf63700d..ce31a3fec 100644 --- a/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag +++ b/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag @@ -23,3 +23,4 @@ unresolved app.jsx:9:14 FUNCTION_CALL memo callee_untyped unresolved defmemo.jsx:4:16 FUNCTION_CALL memo callee_untyped unresolved parts.jsx:6:24 FUNCTION_CALL memo callee_untyped unresolved parts.jsx:6:29 FUNCTION_CALL forwardRef callee_untyped +value_callee app.jsx:42:3 Card module_variable diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.diag b/graph/test/javascript/expected/64-memoize-wrapper-result.diag index f9fd9b4b2..2b978672b 100644 --- a/graph/test/javascript/expected/64-memoize-wrapper-result.diag +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.diag @@ -1,4 +1,8 @@ package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved declared.js:9:34 FUNCTION_CALL fn callee_untyped +unresolved declared.js:9:34 FUNCTION_CALL fn param_fan_capped unresolved memoize.js:7:14 FUNCTION_CALL fn callee_untyped unresolved memoize.js:7:14 FUNCTION_CALL fn param_fan_capped +unresolved shapes.js:55:3 METHOD_CALL run member_absent +value_callee declared.js:9:34 fn parameter value_callee memoize.js:7:14 fn parameter diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.edges b/graph/test/javascript/expected/64-memoize-wrapper-result.edges index 68101987a..c3d46e256 100644 --- a/graph/test/javascript/expected/64-memoize-wrapper-result.edges +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.edges @@ -35,6 +35,92 @@ app.js:7:18 FUNCTION_CALL memoize -> callback_registered app.js:7:26 app.js:7:18 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 app.js:8:17 FUNCTION_CALL memoize -> callback_registered app.js:8:25 app.js:8:17 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +declared.js:15:22 FUNCTION_CALL g -> known_edge declared.js:26:28 +declared.js:15:34 CONSTRUCTOR_CALL Mark -> implicit_constructor - +declared.js:19:28 CONSTRUCTOR_CALL Mark -> implicit_constructor - +declared.js:20:27 FUNCTION_CALL fn -> known_edge declared.js:27:27 +declared.js:22:19 FUNCTION_CALL onceDecl -> callback_registered declared.js:22:28 +declared.js:22:19 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:22:34 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +declared.js:23:18 FUNCTION_CALL onceDecl -> callback_registered declared.js:23:27 +declared.js:23:18 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:23:33 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +declared.js:24:18 FUNCTION_CALL memoize -> callback_registered declared.js:24:26 +declared.js:24:18 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +declared.js:24:32 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +declared.js:25:22 FUNCTION_CALL onceDecl -> callback_registered memoize.js:5:10 +declared.js:25:22 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:26:20 FUNCTION_CALL tapDecl -> callback_registered declared.js:26:28 +declared.js:26:20 FUNCTION_CALL tapDecl -> known_edge declared.js:14:1 tapDecl +declared.js:26:34 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +declared.js:27:16 FUNCTION_CALL pickShared -> callback_registered declared.js:27:27 +declared.js:27:16 FUNCTION_CALL pickShared -> known_edge declared.js:20:1 pickShared +declared.js:27:33 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +declared.js:30:3 FUNCTION_CALL declAlpha -> known_edge declared.js:9:3 inner +declared.js:30:3 METHOD_CALL declAlpha().run -> known_edge alpha.js:3:3 run +declared.js:31:3 FUNCTION_CALL declBeta -> known_edge declared.js:9:3 inner +declared.js:31:3 METHOD_CALL declBeta().run -> known_edge beta.js:3:3 run +declared.js:32:3 FUNCTION_CALL declOfGetter -> known_edge declared.js:9:3 inner +declared.js:32:3 METHOD_CALL declOfGetter().run -> known_edge alpha.js:3:3 run +declared.js:33:3 FUNCTION_CALL tappedDecl -> known_edge declared.js:15:3 inner +declared.js:33:3 METHOD_CALL tappedDecl().markOnly -> known_edge declared.js:13:14 markOnly +declared.js:34:3 FUNCTION_CALL picked -> known_edge declared.js:19:1 shared +declared.js:34:3 METHOD_CALL picked().markOnly -> known_edge declared.js:13:14 markOnly +declared.js:37:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:37:22 +declared.js:37:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:37:44 FUNCTION_CALL onceDecl -> callback_registered declared.js:37:53 +declared.js:37:44 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:37:75 FUNCTION_CALL onceDecl -> callback_registered declared.js:37:84 +declared.js:37:75 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:38:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:38:22 +declared.js:38:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:38:44 FUNCTION_CALL onceDecl -> callback_registered declared.js:38:53 +declared.js:38:44 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:38:75 FUNCTION_CALL onceDecl -> callback_registered declared.js:38:84 +declared.js:38:75 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:39:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:39:22 +declared.js:39:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:39:44 FUNCTION_CALL onceDecl -> callback_registered declared.js:39:53 +declared.js:39:44 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:39:75 FUNCTION_CALL onceDecl -> callback_registered declared.js:39:84 +declared.js:39:75 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:40:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:40:22 +declared.js:40:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:40:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:40:54 +declared.js:40:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:40:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:40:86 +declared.js:40:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:41:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:41:22 +declared.js:41:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:41:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:41:54 +declared.js:41:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:41:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:41:86 +declared.js:41:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:42:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:42:22 +declared.js:42:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:42:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:42:54 +declared.js:42:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:42:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:42:86 +declared.js:42:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:43:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:43:22 +declared.js:43:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:43:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:43:54 +declared.js:43:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:43:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:43:86 +declared.js:43:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:44:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:44:22 +declared.js:44:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:44:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:44:54 +declared.js:44:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:44:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:44:86 +declared.js:44:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:45:13 FUNCTION_CALL onceDecl -> callback_registered declared.js:45:22 +declared.js:45:13 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:45:45 FUNCTION_CALL onceDecl -> callback_registered declared.js:45:54 +declared.js:45:45 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:45:77 FUNCTION_CALL onceDecl -> callback_registered declared.js:45:86 +declared.js:45:77 FUNCTION_CALL onceDecl -> known_edge declared.js:7:1 onceDecl +declared.js:9:34 FUNCTION_CALL fn -> ambiguous_unknown - many.js:3:13 FUNCTION_CALL memoize -> callback_registered many.js:3:21 many.js:3:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 many.js:3:43 FUNCTION_CALL memoize -> callback_registered many.js:3:51 @@ -84,3 +170,56 @@ once.js:23:18 FUNCTION_CALL g -> known_edge app.js:30:20 once.js:23:30 CONSTRUCTOR_CALL Gamma -> implicit_constructor - once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:16:25 once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:18:24 +shapes.js:13:18 CONSTRUCTOR_CALL Fallback -> implicit_constructor - +shapes.js:15:16 FUNCTION_CALL fn -> known_edge shapes.js:36:28 +shapes.js:22:48 FUNCTION_CALL make -> known_edge shapes.js:38:22 +shapes.js:29:53 FUNCTION_CALL fn -> known_edge shapes.js:39:26 +shapes.js:32:27 CONSTRUCTOR_CALL Defaulted -> implicit_constructor - +shapes.js:33:50 FUNCTION_CALL make -> known_edge shapes.js:32:21 +shapes.js:34:38 FUNCTION_CALL fn -> known_edge shapes.js:43:20 +shapes.js:34:44 FUNCTION_CALL c -> known_edge shapes.js:34:32 +shapes.js:34:44 METHOD_CALL c().run -> known_edge alpha.js:3:3 run +shapes.js:36:15 FUNCTION_CALL withFallback -> callback_registered shapes.js:36:28 +shapes.js:36:15 FUNCTION_CALL withFallback -> known_edge shapes.js:14:1 withFallback +shapes.js:36:34 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +shapes.js:37:1 FUNCTION_CALL setOverride -> known_edge shapes.js:19:1 setOverride +shapes.js:37:13 CONSTRUCTOR_CALL Mock -> implicit_constructor - +shapes.js:38:15 FUNCTION_CALL lazyOr -> callback_registered shapes.js:38:22 +shapes.js:38:15 FUNCTION_CALL lazyOr -> known_edge shapes.js:20:1 lazyOr +shapes.js:38:28 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +shapes.js:39:14 FUNCTION_CALL memoDefault -> callback_registered shapes.js:39:26 +shapes.js:39:14 FUNCTION_CALL memoDefault -> known_edge shapes.js:26:1 memoDefault +shapes.js:39:32 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +shapes.js:40:20 FUNCTION_CALL lazy -> known_edge shapes.js:33:1 lazy +shapes.js:41:21 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +shapes.js:42:19 FUNCTION_CALL lazy -> known_edge shapes.js:33:1 lazy +shapes.js:43:14 FUNCTION_CALL eager -> callback_registered shapes.js:43:20 +shapes.js:43:14 FUNCTION_CALL eager -> known_edge shapes.js:34:1 eager +shapes.js:43:26 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +shapes.js:45:19 FUNCTION_CALL memoize -> callback_registered memoize.js:5:10 +shapes.js:45:19 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +shapes.js:45:27 FUNCTION_CALL memoize -> callback_registered shapes.js:45:35 +shapes.js:45:27 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +shapes.js:45:41 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +shapes.js:48:3 FUNCTION_CALL getFb -> known_edge shapes.js:15:10 +shapes.js:48:3 METHOD_CALL getFb().run -> known_edge alpha.js:3:3 run +shapes.js:49:3 FUNCTION_CALL getFb -> known_edge shapes.js:15:10 +shapes.js:49:3 METHOD_CALL getFb().fallbackOnly -> known_edge shapes.js:8:18 fallbackOnly +shapes.js:50:3 FUNCTION_CALL getOr -> known_edge shapes.js:22:10 +shapes.js:50:3 METHOD_CALL getOr().run -> ambient_terminal - +shapes.js:50:3 METHOD_CALL getOr().run -> multi_inferred beta.js:3:3 run +shapes.js:51:3 FUNCTION_CALL getOr -> known_edge shapes.js:22:10 +shapes.js:51:3 METHOD_CALL getOr().mockOnly -> ambient_terminal - +shapes.js:51:3 METHOD_CALL getOr().mockOnly -> multi_inferred shapes.js:9:14 mockOnly +shapes.js:52:3 FUNCTION_CALL getD -> known_edge shapes.js:29:10 +shapes.js:52:3 METHOD_CALL getD().run -> known_edge alpha.js:3:3 run +shapes.js:53:3 FUNCTION_CALL getD -> known_edge shapes.js:29:10 +shapes.js:53:3 METHOD_CALL getD().defaultsOnly -> known_edge shapes.js:25:20 defaultsOnly +shapes.js:54:3 FUNCTION_CALL getDefault -> known_edge shapes.js:33:44 +shapes.js:54:3 METHOD_CALL getDefault().defaultOnly -> known_edge shapes.js:10:19 defaultOnly +shapes.js:55:3 FUNCTION_CALL getSpread -> known_edge shapes.js:33:44 +shapes.js:55:3 METHOD_CALL getSpread().run -> ambiguous_unknown - +shapes.js:56:3 FUNCTION_CALL warm -> known_edge shapes.js:34:32 +shapes.js:56:3 METHOD_CALL warm().run -> known_edge alpha.js:3:3 run +shapes.js:57:3 FUNCTION_CALL getNested -> known_edge memoize.js:5:10 +shapes.js:57:3 METHOD_CALL getNested().run -> known_edge beta.js:3:3 run diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.oracle b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle index c64a7851d..312255368 100644 --- a/graph/test/javascript/expected/64-memoize-wrapper-result.oracle +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle @@ -13,6 +13,48 @@ app.js:30:26 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK app.js:34:3 METHOD_CALL render EXACT once.js:25:15 app.js:7:18 FUNCTION_CALL memoize EXACT memoize.js:2:17 app.js:8:17 FUNCTION_CALL memoize EXACT memoize.js:2:17 +declared.js:15:34 CONSTRUCTOR_CALL Mark SYNTHESIZED_OK +declared.js:19:28 CONSTRUCTOR_CALL Mark SYNTHESIZED_OK +declared.js:22:19 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:22:34 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +declared.js:23:18 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:23:33 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +declared.js:24:18 FUNCTION_CALL memoize EXACT memoize.js:2:17 +declared.js:24:32 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +declared.js:25:22 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:26:20 FUNCTION_CALL tapDecl EXACT declared.js:14:1 +declared.js:26:34 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +declared.js:27:16 FUNCTION_CALL pickShared EXACT declared.js:20:1 +declared.js:27:33 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +declared.js:33:3 METHOD_CALL markOnly EXACT declared.js:13:14 +declared.js:34:3 METHOD_CALL markOnly EXACT declared.js:13:14 +declared.js:37:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:37:44 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:37:75 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:38:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:38:44 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:38:75 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:39:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:39:44 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:39:75 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:40:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:40:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:40:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:41:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:41:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:41:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:42:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:42:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:42:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:43:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:43:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:43:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:44:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:44:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:44:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:45:13 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:45:45 FUNCTION_CALL onceDecl EXACT declared.js:7:1 +declared.js:45:77 FUNCTION_CALL onceDecl EXACT declared.js:7:1 many.js:3:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 many.js:3:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 many.js:3:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 @@ -35,4 +77,26 @@ many.js:9:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 many.js:9:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 many.js:9:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 once.js:23:30 CONSTRUCTOR_CALL Gamma SYNTHESIZED_OK +shapes.js:13:18 CONSTRUCTOR_CALL Fallback SYNTHESIZED_OK +shapes.js:32:27 CONSTRUCTOR_CALL Defaulted SYNTHESIZED_OK +shapes.js:33:50 FUNCTION_CALL make EXACT shapes.js:32:21 +shapes.js:34:44 FUNCTION_CALL c EXACT shapes.js:34:32 +shapes.js:36:15 FUNCTION_CALL withFallback EXACT shapes.js:14:1 +shapes.js:36:34 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +shapes.js:37:1 FUNCTION_CALL setOverride EXACT shapes.js:19:1 +shapes.js:37:13 CONSTRUCTOR_CALL Mock SYNTHESIZED_OK +shapes.js:38:15 FUNCTION_CALL lazyOr EXACT shapes.js:20:1 +shapes.js:38:28 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +shapes.js:39:14 FUNCTION_CALL memoDefault EXACT shapes.js:26:1 +shapes.js:39:32 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +shapes.js:40:20 FUNCTION_CALL lazy EXACT shapes.js:33:1 +shapes.js:41:21 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +shapes.js:42:19 FUNCTION_CALL lazy EXACT shapes.js:33:1 +shapes.js:43:14 FUNCTION_CALL eager EXACT shapes.js:34:1 +shapes.js:43:26 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +shapes.js:45:19 FUNCTION_CALL memoize EXACT memoize.js:2:17 +shapes.js:45:27 FUNCTION_CALL memoize EXACT memoize.js:2:17 +shapes.js:45:41 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +shapes.js:53:3 METHOD_CALL defaultsOnly EXACT shapes.js:25:20 +shapes.js:54:3 METHOD_CALL defaultOnly EXACT shapes.js:10:19 # defects: 0 diff --git a/graph/test/javascript/expected/65-stored-value-callee.diag b/graph/test/javascript/expected/65-stored-value-callee.diag new file mode 100644 index 000000000..bf13ca669 --- /dev/null +++ b/graph/test/javascript/expected/65-stored-value-callee.diag @@ -0,0 +1,26 @@ +import_cause stored.js:2:14 path builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved stored.js:13:76 METHOD_CALL onReady receiver_untyped +unresolved stored.js:14:48 METHOD_CALL onDone receiver_untyped +unresolved stored.js:15:43 METHOD_CALL onEnd receiver_untyped +unresolved stored.js:16:38 OPTIONAL_CALL onStart receiver_untyped +unresolved stored.js:17:39 FUNCTION_CALL callee_untyped +unresolved stored.js:18:33 FUNCTION_CALL callee_untyped +unresolved stored.js:19:28 CONSTRUCTOR_CALL Function no_target +unresolved stored.js:19:28 FUNCTION_CALL no_target +unresolved stored.js:22:27 METHOD_CALL join no_target +unresolved stored.js:23:33 METHOD_CALL trim no_target +unresolved stored.js:25:35 METHOD_CALL onReady receiver_untyped +unresolved stored.js:26:37 METHOD_CALL send receiver_untyped +unresolved stored.js:26:37 METHOD_CALL status receiver_untyped +unresolved stored.js:6:26 FUNCTION_CALL handler no_target +unresolved stored.js:9:19 METHOD_CALL cb member_absent +value_callee stored.js:13:76 opts.onReady parameter_member +value_callee stored.js:14:48 opts.onDone parameter_member +value_callee stored.js:15:43 opts.onEnd parameter_member +value_callee stored.js:16:38 opts.onStart parameter_member +value_callee stored.js:17:39 (c ? a : b) expression +value_callee stored.js:18:33 (0, cb) expression +value_callee stored.js:19:28 new Function(s) dynamic_code +value_callee stored.js:6:26 handler module_variable +value_callee stored.js:9:19 this.cb field diff --git a/graph/test/javascript/expected/65-stored-value-callee.edges b/graph/test/javascript/expected/65-stored-value-callee.edges new file mode 100644 index 000000000..de30a1ecc --- /dev/null +++ b/graph/test/javascript/expected/65-stored-value-callee.edges @@ -0,0 +1,18 @@ +stored.js:11:23 METHOD_CALL this.own -> known_edge stored.js:10:3 own +stored.js:13:76 METHOD_CALL opts.onReady -> ambiguous_unknown - +stored.js:14:48 METHOD_CALL opts.onDone -> ambiguous_unknown - +stored.js:15:43 METHOD_CALL opts.onEnd -> ambiguous_unknown - +stored.js:16:38 OPTIONAL_CALL opts.onStart -> ambiguous_unknown - +stored.js:17:39 FUNCTION_CALL (c ? a : b) -> ambiguous_unknown - +stored.js:18:33 FUNCTION_CALL (0, cb) -> ambiguous_unknown - +stored.js:19:28 CONSTRUCTOR_CALL Function -> ambient_terminal - +stored.js:19:28 FUNCTION_CALL new Function(s) -> ambient_terminal - +stored.js:20:37 FUNCTION_CALL alpha -> known_edge stored.js:3:1 alpha +stored.js:21:31 FUNCTION_CALL helper -> known_edge stored.js:20:16 +stored.js:22:27 METHOD_CALL path.join -> ambient_terminal - +stored.js:23:33 METHOD_CALL 'abc'.trim -> ambient_terminal - +stored.js:25:35 METHOD_CALL opts.onReady -> ambiguous_unknown - +stored.js:26:37 METHOD_CALL res.status -> ambiguous_unknown - +stored.js:26:37 METHOD_CALL res.status(200).send -> ambiguous_unknown - +stored.js:6:26 FUNCTION_CALL handler -> ambient_terminal - +stored.js:9:19 METHOD_CALL this.cb -> ambiguous_unknown - diff --git a/graph/test/javascript/expected/65-stored-value-callee.oracle b/graph/test/javascript/expected/65-stored-value-callee.oracle new file mode 100644 index 000000000..5d93afb30 --- /dev/null +++ b/graph/test/javascript/expected/65-stored-value-callee.oracle @@ -0,0 +1,6 @@ +stored.js:11:23 METHOD_CALL own EXACT stored.js:10:3 +stored.js:19:28 CONSTRUCTOR_CALL Function LIB_AMBIENT_OK +stored.js:20:37 FUNCTION_CALL alpha EXACT stored.js:3:1 +stored.js:21:31 FUNCTION_CALL helper EXACT stored.js:20:16 +stored.js:23:33 METHOD_CALL trim LIB_AMBIENT_OK +# defects: 0 diff --git a/graph/test/javascript/expected/65-wrapper-param-reassigned.diag b/graph/test/javascript/expected/65-wrapper-param-reassigned.diag new file mode 100644 index 000000000..0d5ea8020 --- /dev/null +++ b/graph/test/javascript/expected/65-wrapper-param-reassigned.diag @@ -0,0 +1,10 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved app.js:16:3 METHOD_CALL replacedOnly member_absent +unresolved app.js:18:3 METHOD_CALL replacedOnly member_absent +unresolved app.js:6:3 METHOD_CALL aOnly member_absent +unresolved many.js:12:3 METHOD_CALL aOnly member_absent +unresolved wrap.js:24:16 FUNCTION_CALL fn callee_untyped +unresolved wrap.js:9:16 FUNCTION_CALL fn callee_untyped +unresolved wrap.js:9:16 FUNCTION_CALL fn param_fan_capped +value_callee wrap.js:24:16 fn parameter +value_callee wrap.js:9:16 fn parameter diff --git a/graph/test/javascript/expected/65-wrapper-param-reassigned.edges b/graph/test/javascript/expected/65-wrapper-param-reassigned.edges new file mode 100644 index 000000000..d56c695d6 --- /dev/null +++ b/graph/test/javascript/expected/65-wrapper-param-reassigned.edges @@ -0,0 +1,127 @@ +app.js:10:23 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:10:3 FUNCTION_CALL lazyOrDefault -> callback_registered app.js:10:17 +app.js:10:3 FUNCTION_CALL lazyOrDefault -> known_edge wrap.js:17:1 lazyOrDefault +app.js:10:3 FUNCTION_CALL lazyOrDefault(() => new A()) -> known_edge wrap.js:19:10 +app.js:10:3 METHOD_CALL lazyOrDefault(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly +app.js:11:3 FUNCTION_CALL lazyNullish -> known_edge wrap.js:22:1 lazyNullish +app.js:11:3 FUNCTION_CALL lazyNullish() -> known_edge wrap.js:24:10 +app.js:11:3 METHOD_CALL lazyNullish()().dfltOnly -> known_edge wrap.js:4:14 dfltOnly +app.js:15:15 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:15:3 FUNCTION_CALL plain -> callback_registered app.js:15:9 +app.js:15:3 FUNCTION_CALL plain -> known_edge wrap.js:27:1 plain +app.js:15:3 FUNCTION_CALL plain(() => new A()) -> known_edge wrap.js:28:10 +app.js:15:3 METHOD_CALL plain(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly +app.js:16:15 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:16:3 FUNCTION_CALL plain -> callback_registered app.js:16:9 +app.js:16:3 FUNCTION_CALL plain -> known_edge wrap.js:27:1 plain +app.js:16:3 FUNCTION_CALL plain(() => new A()) -> known_edge wrap.js:28:10 +app.js:16:3 METHOD_CALL plain(() => new A())().replacedOnly -> ambiguous_unknown - +app.js:17:22 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:17:3 FUNCTION_CALL otherWritten -> callback_registered app.js:17:16 +app.js:17:3 FUNCTION_CALL otherWritten -> known_edge wrap.js:31:1 otherWritten +app.js:17:3 FUNCTION_CALL otherWritten(() => new A(), null) -> known_edge wrap.js:33:10 +app.js:17:3 METHOD_CALL otherWritten(() => new A(), null)().aOnly -> known_edge wrap.js:2:11 aOnly +app.js:18:22 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:18:3 FUNCTION_CALL otherWritten -> callback_registered app.js:18:16 +app.js:18:3 FUNCTION_CALL otherWritten -> known_edge wrap.js:31:1 otherWritten +app.js:18:3 FUNCTION_CALL otherWritten(() => new A(), null) -> known_edge wrap.js:33:10 +app.js:18:3 METHOD_CALL otherWritten(() => new A(), null)().replacedOnly -> ambiguous_unknown - +app.js:4:14 FUNCTION_CALL swapped -> callback_registered app.js:4:22 +app.js:4:14 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +app.js:4:28 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:5:3 FUNCTION_CALL sw -> known_edge wrap.js:9:10 +app.js:5:3 METHOD_CALL sw().replacedOnly -> known_edge wrap.js:3:18 replacedOnly +app.js:6:3 FUNCTION_CALL sw -> known_edge wrap.js:9:10 +app.js:6:3 METHOD_CALL sw().aOnly -> ambiguous_unknown - +app.js:7:3 FUNCTION_CALL lazyGuard -> known_edge wrap.js:12:1 lazyGuard +app.js:7:3 FUNCTION_CALL lazyGuard(undefined) -> known_edge wrap.js:14:10 +app.js:7:3 METHOD_CALL lazyGuard(undefined)().replacedOnly -> known_edge wrap.js:3:18 replacedOnly +app.js:8:19 CONSTRUCTOR_CALL A -> implicit_constructor - +app.js:8:3 FUNCTION_CALL lazyGuard -> callback_registered app.js:8:13 +app.js:8:3 FUNCTION_CALL lazyGuard -> known_edge wrap.js:12:1 lazyGuard +app.js:8:3 FUNCTION_CALL lazyGuard(() => new A()) -> known_edge wrap.js:14:10 +app.js:8:3 METHOD_CALL lazyGuard(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly +app.js:9:3 FUNCTION_CALL lazyOrDefault -> known_edge wrap.js:17:1 lazyOrDefault +app.js:9:3 FUNCTION_CALL lazyOrDefault() -> known_edge wrap.js:19:10 +app.js:9:3 METHOD_CALL lazyOrDefault()().dfltOnly -> known_edge wrap.js:4:14 dfltOnly +many.js:11:3 FUNCTION_CALL s21 -> known_edge wrap.js:9:10 +many.js:11:3 METHOD_CALL s21().replacedOnly -> known_edge wrap.js:3:18 replacedOnly +many.js:12:3 FUNCTION_CALL s21 -> known_edge wrap.js:9:10 +many.js:12:3 METHOD_CALL s21().aOnly -> ambiguous_unknown - +many.js:3:13 FUNCTION_CALL swapped -> callback_registered many.js:3:21 +many.js:3:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:3:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:3:49 FUNCTION_CALL swapped -> callback_registered many.js:3:57 +many.js:3:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:3:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:3:85 FUNCTION_CALL swapped -> callback_registered many.js:3:93 +many.js:3:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:3:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:4:13 FUNCTION_CALL swapped -> callback_registered many.js:4:21 +many.js:4:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:4:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:4:49 FUNCTION_CALL swapped -> callback_registered many.js:4:57 +many.js:4:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:4:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:4:85 FUNCTION_CALL swapped -> callback_registered many.js:4:93 +many.js:4:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:4:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:5:13 FUNCTION_CALL swapped -> callback_registered many.js:5:21 +many.js:5:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:5:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:5:49 FUNCTION_CALL swapped -> callback_registered many.js:5:57 +many.js:5:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:5:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:5:85 FUNCTION_CALL swapped -> callback_registered many.js:5:93 +many.js:5:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:5:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:6:13 FUNCTION_CALL swapped -> callback_registered many.js:6:21 +many.js:6:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:6:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:6:49 FUNCTION_CALL swapped -> callback_registered many.js:6:57 +many.js:6:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:6:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:6:85 FUNCTION_CALL swapped -> callback_registered many.js:6:93 +many.js:6:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:6:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:7:13 FUNCTION_CALL swapped -> callback_registered many.js:7:21 +many.js:7:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:7:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:7:49 FUNCTION_CALL swapped -> callback_registered many.js:7:57 +many.js:7:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:7:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:7:85 FUNCTION_CALL swapped -> callback_registered many.js:7:93 +many.js:7:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:7:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:8:13 FUNCTION_CALL swapped -> callback_registered many.js:8:21 +many.js:8:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:8:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:8:49 FUNCTION_CALL swapped -> callback_registered many.js:8:57 +many.js:8:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:8:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:8:85 FUNCTION_CALL swapped -> callback_registered many.js:8:93 +many.js:8:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:8:99 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:9:13 FUNCTION_CALL swapped -> callback_registered many.js:9:21 +many.js:9:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:9:27 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:9:49 FUNCTION_CALL swapped -> callback_registered many.js:9:57 +many.js:9:49 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:9:63 CONSTRUCTOR_CALL A -> implicit_constructor - +many.js:9:85 FUNCTION_CALL swapped -> callback_registered many.js:9:93 +many.js:9:85 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped +many.js:9:99 CONSTRUCTOR_CALL A -> implicit_constructor - +wrap.js:13:44 CONSTRUCTOR_CALL Replaced -> implicit_constructor - +wrap.js:14:16 FUNCTION_CALL fn -> ambient_terminal - +wrap.js:14:16 FUNCTION_CALL fn -> multi_inferred app.js:8:13 +wrap.js:18:21 CONSTRUCTOR_CALL Dflt -> implicit_constructor - +wrap.js:19:16 FUNCTION_CALL fn -> known_edge app.js:10:17 +wrap.js:23:16 CONSTRUCTOR_CALL Dflt -> implicit_constructor - +wrap.js:24:16 FUNCTION_CALL fn -> ambiguous_unknown - +wrap.js:28:16 FUNCTION_CALL fn -> multi_inferred app.js:15:9 +wrap.js:28:16 FUNCTION_CALL fn -> multi_inferred app.js:16:9 +wrap.js:32:15 CONSTRUCTOR_CALL Replaced -> implicit_constructor - +wrap.js:33:16 FUNCTION_CALL fn -> multi_inferred app.js:17:16 +wrap.js:33:16 FUNCTION_CALL fn -> multi_inferred app.js:18:16 +wrap.js:8:14 CONSTRUCTOR_CALL Replaced -> implicit_constructor - +wrap.js:9:16 FUNCTION_CALL fn -> ambiguous_unknown - diff --git a/graph/test/javascript/expected/65-wrapper-param-reassigned.oracle b/graph/test/javascript/expected/65-wrapper-param-reassigned.oracle new file mode 100644 index 000000000..38e66a71e --- /dev/null +++ b/graph/test/javascript/expected/65-wrapper-param-reassigned.oracle @@ -0,0 +1,65 @@ +app.js:10:23 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:10:3 FUNCTION_CALL lazyOrDefault EXACT wrap.js:17:1 +app.js:11:3 FUNCTION_CALL lazyNullish EXACT wrap.js:22:1 +app.js:15:15 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:15:3 FUNCTION_CALL plain EXACT wrap.js:27:1 +app.js:16:15 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:16:3 FUNCTION_CALL plain EXACT wrap.js:27:1 +app.js:17:22 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:17:3 FUNCTION_CALL otherWritten EXACT wrap.js:31:1 +app.js:18:22 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:18:3 FUNCTION_CALL otherWritten EXACT wrap.js:31:1 +app.js:4:14 FUNCTION_CALL swapped EXACT wrap.js:7:1 +app.js:4:28 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:7:3 FUNCTION_CALL lazyGuard EXACT wrap.js:12:1 +app.js:8:19 CONSTRUCTOR_CALL A SYNTHESIZED_OK +app.js:8:3 FUNCTION_CALL lazyGuard EXACT wrap.js:12:1 +app.js:9:3 FUNCTION_CALL lazyOrDefault EXACT wrap.js:17:1 +many.js:3:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:3:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:3:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:3:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:3:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:3:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:4:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:4:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:4:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:4:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:4:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:4:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:5:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:5:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:5:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:5:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:5:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:5:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:6:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:6:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:6:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:6:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:6:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:6:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:7:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:7:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:7:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:7:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:7:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:7:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:8:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:8:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:8:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:8:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:8:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:8:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:9:13 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:9:27 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:9:49 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:9:63 CONSTRUCTOR_CALL A SYNTHESIZED_OK +many.js:9:85 FUNCTION_CALL swapped EXACT wrap.js:7:1 +many.js:9:99 CONSTRUCTOR_CALL A SYNTHESIZED_OK +wrap.js:13:44 CONSTRUCTOR_CALL Replaced SYNTHESIZED_OK +wrap.js:18:21 CONSTRUCTOR_CALL Dflt SYNTHESIZED_OK +wrap.js:23:16 CONSTRUCTOR_CALL Dflt SYNTHESIZED_OK +wrap.js:32:15 CONSTRUCTOR_CALL Replaced SYNTHESIZED_OK +wrap.js:8:14 CONSTRUCTOR_CALL Replaced SYNTHESIZED_OK +# defects: 0 diff --git a/graph/test/javascript/expected/66-stored-callee-shapes.diag b/graph/test/javascript/expected/66-stored-callee-shapes.diag new file mode 100644 index 000000000..b86fee8ab --- /dev/null +++ b/graph/test/javascript/expected/66-stored-callee-shapes.diag @@ -0,0 +1,24 @@ +import_cause shapes.js:2:15 debug not_staged +import_cause shapes.js:36:13 util builtin +import_cause shapes.js:41:22 events builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved shapes.js:12:18 METHOD_CALL cb no_target +unresolved shapes.js:20:25 METHOD_CALL cb member_absent +unresolved shapes.js:24:25 METHOD_CALL cb no_target +unresolved shapes.js:28:37 METHOD_CALL cb member_absent +unresolved shapes.js:32:19 CONSTRUCTOR_CALL Ctor member_absent +unresolved shapes.js:34:13 FUNCTION_CALL debug callee_untyped +unresolved shapes.js:35:19 FUNCTION_CALL log callee_untyped +unresolved shapes.js:37:26 FUNCTION_CALL tag no_target +unresolved shapes.js:38:17 FUNCTION_CALL debug callee_untyped +unresolved shapes.js:39:34 FUNCTION_CALL wrapped callee_untyped +unresolved shapes.js:42:48 METHOD_CALL emit no_target +unresolved shapes.js:7:18 METHOD_CALL cb no_target +value_callee shapes.js:12:18 this.cb field +value_callee shapes.js:16:18 this.cb field +value_callee shapes.js:20:25 this.cb field +value_callee shapes.js:24:25 this.cb field +value_callee shapes.js:28:37 self.cb field +value_callee shapes.js:32:19 this.Ctor field +value_callee shapes.js:39:34 wrapped module_variable +value_callee shapes.js:7:18 this.cb field diff --git a/graph/test/javascript/expected/66-stored-callee-shapes.edges b/graph/test/javascript/expected/66-stored-callee-shapes.edges new file mode 100644 index 000000000..d3c72d58f --- /dev/null +++ b/graph/test/javascript/expected/66-stored-callee-shapes.edges @@ -0,0 +1,18 @@ +shapes.js:12:18 METHOD_CALL this.cb -> ambient_terminal - +shapes.js:16:18 METHOD_CALL this.cb -> known_edge shapes.js:15:37 +shapes.js:20:25 METHOD_CALL this.cb -> ambiguous_unknown - +shapes.js:24:25 METHOD_CALL this.cb -> ambient_terminal - +shapes.js:28:37 METHOD_CALL self.cb -> ambiguous_unknown - +shapes.js:32:19 CONSTRUCTOR_CALL this.Ctor -> ambiguous_unknown - +shapes.js:34:13 FUNCTION_CALL debug -> ambiguous_unknown - +shapes.js:35:19 FUNCTION_CALL log -> ambiguous_unknown - +shapes.js:37:26 FUNCTION_CALL tag -> ambient_terminal - +shapes.js:38:17 FUNCTION_CALL debug -> ambiguous_unknown - +shapes.js:38:17 FUNCTION_CALL debug -> callback_registered shapes.js:3:1 alpha +shapes.js:39:34 FUNCTION_CALL wrapped -> ambiguous_unknown - +shapes.js:42:48 METHOD_CALL this.emit -> ambient_terminal - +shapes.js:43:48 METHOD_CALL this.own -> known_edge shapes.js:43:13 own +shapes.js:44:65 METHOD_CALL this.fn -> known_edge shapes.js:3:1 alpha +shapes.js:46:31 CONSTRUCTOR_CALL Own -> implicit_constructor - +shapes.js:46:49 METHOD_CALL o.run -> known_edge shapes.js:43:33 run +shapes.js:7:18 METHOD_CALL this.cb -> ambient_terminal - diff --git a/graph/test/javascript/expected/66-stored-callee-shapes.oracle b/graph/test/javascript/expected/66-stored-callee-shapes.oracle new file mode 100644 index 000000000..fb75cc273 --- /dev/null +++ b/graph/test/javascript/expected/66-stored-callee-shapes.oracle @@ -0,0 +1,5 @@ +shapes.js:43:48 METHOD_CALL own EXACT shapes.js:43:13 +shapes.js:44:65 METHOD_CALL fn EXACT shapes.js:3:1 +shapes.js:46:31 CONSTRUCTOR_CALL Own SYNTHESIZED_OK +shapes.js:46:49 METHOD_CALL run EXACT shapes.js:43:33 +# defects: 0 diff --git a/graph/test/javascript/expected/67-imported-live-variable.diag b/graph/test/javascript/expected/67-imported-live-variable.diag new file mode 100644 index 000000000..b11c694b5 --- /dev/null +++ b/graph/test/javascript/expected/67-imported-live-variable.diag @@ -0,0 +1,13 @@ +package_entry imported-live-variable . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved main.js:11:38 FUNCTION_CALL fixed no_target +unresolved main.js:13:40 METHOD_CALL fixed no_target +unresolved main.js:5:41 FUNCTION_CALL handler no_target +unresolved main.js:6:35 METHOD_CALL handler no_target +unresolved main.js:7:42 FUNCTION_CALL hook no_target +unresolved main.js:8:40 FUNCTION_CALL relayed no_target +unresolved main.js:9:41 METHOD_CALL handler no_target +value_callee main.js:5:41 handler imported_variable +value_callee main.js:6:35 hub.handler imported_variable +value_callee main.js:7:42 hook imported_variable +value_callee main.js:8:40 relayed imported_variable +value_callee main.js:9:41 barrel.handler imported_variable diff --git a/graph/test/javascript/expected/67-imported-live-variable.edges b/graph/test/javascript/expected/67-imported-live-variable.edges new file mode 100644 index 000000000..ecb5847f2 --- /dev/null +++ b/graph/test/javascript/expected/67-imported-live-variable.edges @@ -0,0 +1,8 @@ +main.js:11:38 FUNCTION_CALL fixed -> ambient_terminal - +main.js:12:38 FUNCTION_CALL ready -> known_edge hub.js:8:22 +main.js:13:40 METHOD_CALL hub.fixed -> ambient_terminal - +main.js:5:41 FUNCTION_CALL handler -> ambient_terminal - +main.js:6:35 METHOD_CALL hub.handler -> ambient_terminal - +main.js:7:42 FUNCTION_CALL hook -> ambient_terminal - +main.js:8:40 FUNCTION_CALL relayed -> ambient_terminal - +main.js:9:41 METHOD_CALL barrel.handler -> ambient_terminal - diff --git a/graph/test/javascript/expected/67-imported-live-variable.oracle b/graph/test/javascript/expected/67-imported-live-variable.oracle new file mode 100644 index 000000000..1fe7b6d24 --- /dev/null +++ b/graph/test/javascript/expected/67-imported-live-variable.oracle @@ -0,0 +1,2 @@ +main.js:12:38 FUNCTION_CALL ready EXACT hub.js:8:22 +# defects: 0 diff --git a/graph/test/javascript/expected/68-dispatch-map-open-key.diag b/graph/test/javascript/expected/68-dispatch-map-open-key.diag new file mode 100644 index 000000000..d66df7933 --- /dev/null +++ b/graph/test/javascript/expected/68-dispatch-map-open-key.diag @@ -0,0 +1,10 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved handlers.js:4:40 METHOD_CALL filter receiver_untyped +unresolved store.js:10:14 FUNCTION_CALL h callee_untyped +unresolved store.js:15:10 COMPUTED_CALL dynamic +unresolved store.js:20:10 COMPUTED_CALL dynamic +unresolved store.js:39:27 COMPUTED_CALL dynamic +value_callee store.js:10:14 h local +value_callee store.js:15:10 handlers[type] computed +value_callee store.js:20:10 cli.commands[argv[0]] computed +value_callee store.js:39:27 this[verb] computed diff --git a/graph/test/javascript/expected/68-dispatch-map-open-key.edges b/graph/test/javascript/expected/68-dispatch-map-open-key.edges new file mode 100644 index 000000000..d79ec043d --- /dev/null +++ b/graph/test/javascript/expected/68-dispatch-map-open-key.edges @@ -0,0 +1,17 @@ +handlers.js:4:40 METHOD_CALL state.filter -> ambiguous_unknown - +handlers.js:4:40 METHOD_CALL state.filter -> callback_registered handlers.js:4:53 +store.js:10:14 FUNCTION_CALL h -> multi_inferred handlers.js:3:1 addItem +store.js:10:14 FUNCTION_CALL h -> multi_inferred handlers.js:4:1 removeItem +store.js:10:14 FUNCTION_CALL h -> multi_inferred handlers.js:5:68 +store.js:15:10 COMPUTED_CALL handlers[type] -> multi_inferred handlers.js:3:1 addItem +store.js:15:10 COMPUTED_CALL handlers[type] -> multi_inferred handlers.js:4:1 removeItem +store.js:15:10 COMPUTED_CALL handlers[type] -> multi_inferred handlers.js:5:68 +store.js:20:10 COMPUTED_CALL cli.commands[argv[0]] -> multi_inferred handlers.js:7:1 runBuild +store.js:20:10 COMPUTED_CALL cli.commands[argv[0]] -> multi_inferred handlers.js:8:1 runServe +store.js:24:40 METHOD_CALL handlers.ADD -> known_edge handlers.js:3:1 addItem +store.js:27:43 COMPUTED_CALL handlers['REMOVE'] -> known_edge handlers.js:4:1 removeItem +store.js:32:47 COMPUTED_CALL handlers[KEY] -> known_edge handlers.js:3:1 addItem +store.js:39:27 COMPUTED_CALL this[verb] -> dynamic_terminal - +store.js:41:23 CONSTRUCTOR_CALL Router -> implicit_constructor - +store.js:47:37 COMPUTED_CALL steps[i] -> multi_inferred store.js:45:1 addItem2 +store.js:47:37 COMPUTED_CALL steps[i] -> multi_inferred store.js:46:1 removeItem2 diff --git a/graph/test/javascript/expected/68-dispatch-map-open-key.oracle b/graph/test/javascript/expected/68-dispatch-map-open-key.oracle new file mode 100644 index 000000000..9fd1556ed --- /dev/null +++ b/graph/test/javascript/expected/68-dispatch-map-open-key.oracle @@ -0,0 +1,4 @@ +store.js:24:40 METHOD_CALL ADD EXACT handlers.js:3:1 +store.js:27:43 COMPUTED_CALL EXACT handlers.js:4:1 +store.js:41:23 CONSTRUCTOR_CALL Router SYNTHESIZED_OK +# defects: 0 diff --git a/graph/test/javascript/expected/68-instance-member-callee.diag b/graph/test/javascript/expected/68-instance-member-callee.diag new file mode 100644 index 000000000..d6ed769f8 --- /dev/null +++ b/graph/test/javascript/expected/68-instance-member-callee.diag @@ -0,0 +1,11 @@ +import_cause index.js:19:14 some-unstaged-base not_staged +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved index.js:12:18 METHOD_CALL x member_absent +unresolved index.js:20:41 METHOD_CALL emit member_absent +unresolved index.js:22:31 METHOD_CALL hasOwnProperty member_absent +unresolved index.js:4:33 METHOD_CALL hook member_absent +unresolved index.js:7:23 METHOD_CALL assign no_target +unresolved index.js:8:18 METHOD_CALL onReady member_absent +value_callee index.js:12:18 this.x field +value_callee index.js:4:33 this.hook instance_member +value_callee index.js:8:18 this.onReady instance_member diff --git a/graph/test/javascript/expected/68-instance-member-callee.edges b/graph/test/javascript/expected/68-instance-member-callee.edges new file mode 100644 index 000000000..68cc3a334 --- /dev/null +++ b/graph/test/javascript/expected/68-instance-member-callee.edges @@ -0,0 +1,7 @@ +index.js:12:18 METHOD_CALL this.x -> ambiguous_unknown - +index.js:17:18 METHOD_CALL this.own -> known_edge index.js:16:3 own +index.js:20:41 METHOD_CALL this.emit -> ambiguous_unknown - +index.js:22:31 METHOD_CALL this.hasOwnProperty -> ambient_terminal - +index.js:4:33 METHOD_CALL this.hook -> ambiguous_unknown - +index.js:7:23 METHOD_CALL Object.assign -> ambient_terminal - +index.js:8:18 METHOD_CALL this.onReady -> ambiguous_unknown - diff --git a/graph/test/javascript/expected/68-instance-member-callee.oracle b/graph/test/javascript/expected/68-instance-member-callee.oracle new file mode 100644 index 000000000..9512748e8 --- /dev/null +++ b/graph/test/javascript/expected/68-instance-member-callee.oracle @@ -0,0 +1,4 @@ +index.js:17:18 METHOD_CALL own EXACT index.js:16:3 +index.js:22:31 METHOD_CALL hasOwnProperty LIB_AMBIENT_OK +index.js:7:23 METHOD_CALL assign LIB_AMBIENT_OK +# defects: 0 diff --git a/graph/test/javascript/expected/69-this-constructor-per-site.diag b/graph/test/javascript/expected/69-this-constructor-per-site.diag new file mode 100644 index 000000000..71b17c3dd --- /dev/null +++ b/graph/test/javascript/expected/69-this-constructor-per-site.diag @@ -0,0 +1,2 @@ +package_entry this-constructor-per-site . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved main.js:17:20 METHOD_CALL create no_target diff --git a/graph/test/javascript/expected/69-this-constructor-per-site.edges b/graph/test/javascript/expected/69-this-constructor-per-site.edges new file mode 100644 index 000000000..b94812d28 --- /dev/null +++ b/graph/test/javascript/expected/69-this-constructor-per-site.edges @@ -0,0 +1,41 @@ +main.js:14:44 CONSTRUCTOR_CALL this.constructor -> multi_inferred main.js:13:1 Shape +main.js:14:44 CONSTRUCTOR_CALL this.constructor -> multi_inferred main.js:16:1 Circle +main.js:16:21 FUNCTION_CALL_CALL Shape.call -> known_edge main.js:13:1 Shape +main.js:17:20 METHOD_CALL Object.create -> ambient_terminal - +main.js:23:23 CONSTRUCTOR_CALL this.constructor -> implicit_constructor - +main.js:25:20 CONSTRUCTOR_CALL this.constructor -> implicit_constructor - +main.js:31:3 CONSTRUCTOR_CALL Select -> implicit_constructor - +main.js:31:3 METHOD_CALL new Select().clone -> known_edge main.js:23:3 clone +main.js:31:3 METHOD_CALL new Select().clone().exec -> known_edge main.js:28:30 exec +main.js:32:3 CONSTRUCTOR_CALL Query -> implicit_constructor - +main.js:32:3 METHOD_CALL new Query().clone -> known_edge main.js:23:3 clone +main.js:32:3 METHOD_CALL new Query().clone().exec -> known_edge main.js:26:3 exec +main.js:33:3 CONSTRUCTOR_CALL Select -> implicit_constructor - +main.js:33:3 METHOD_CALL new Select().fork -> known_edge main.js:25:3 fork +main.js:33:3 METHOD_CALL new Select().fork().exec -> multi_inferred main.js:26:3 exec +main.js:33:3 METHOD_CALL new Select().fork().exec -> multi_inferred main.js:28:30 exec +main.js:34:3 CONSTRUCTOR_CALL Sub -> implicit_constructor - +main.js:34:3 METHOD_CALL new Sub().clone -> known_edge main.js:5:3 clone +main.js:34:3 METHOD_CALL new Sub().clone().run -> known_edge main.js:9:26 run +main.js:35:3 CONSTRUCTOR_CALL Other -> implicit_constructor - +main.js:35:3 METHOD_CALL new Other().clone -> known_edge main.js:5:3 clone +main.js:35:3 METHOD_CALL new Other().clone().run -> known_edge main.js:10:28 run +main.js:36:3 CONSTRUCTOR_CALL Sub -> implicit_constructor - +main.js:36:3 METHOD_CALL new Sub().clone -> known_edge main.js:5:3 clone +main.js:36:3 METHOD_CALL new Sub().clone().clone -> known_edge main.js:5:3 clone +main.js:36:3 METHOD_CALL new Sub().clone().clone().run -> known_edge main.js:9:26 run +main.js:37:3 CONSTRUCTOR_CALL Base -> implicit_constructor - +main.js:37:3 METHOD_CALL new Base().clone -> known_edge main.js:5:3 clone +main.js:37:3 METHOD_CALL new Base().clone().run -> known_edge main.js:7:3 run +main.js:39:3 CONSTRUCTOR_CALL Sub -> implicit_constructor - +main.js:39:3 METHOD_CALL new Sub().copy -> known_edge main.js:6:3 copy +main.js:39:3 METHOD_CALL new Sub().copy().run -> known_edge main.js:7:3 run +main.js:40:3 CONSTRUCTOR_CALL Circle -> known_edge main.js:16:1 Circle +main.js:40:3 METHOD_CALL new Circle().dup -> known_edge main.js:14:23 dup +main.js:40:3 METHOD_CALL new Circle().dup().area -> known_edge main.js:19:25 area +main.js:41:3 CONSTRUCTOR_CALL Shape -> known_edge main.js:13:1 Shape +main.js:41:3 METHOD_CALL new Shape().dup -> known_edge main.js:14:23 dup +main.js:41:3 METHOD_CALL new Shape().dup().area -> known_edge main.js:15:24 area +main.js:43:1 FUNCTION_CALL main -> known_edge main.js:30:1 main +main.js:5:20 CONSTRUCTOR_CALL this.constructor -> implicit_constructor - +main.js:6:19 CONSTRUCTOR_CALL Base -> implicit_constructor - diff --git a/graph/test/javascript/expected/69-this-constructor-per-site.oracle b/graph/test/javascript/expected/69-this-constructor-per-site.oracle new file mode 100644 index 000000000..9a8085756 --- /dev/null +++ b/graph/test/javascript/expected/69-this-constructor-per-site.oracle @@ -0,0 +1,29 @@ +main.js:14:44 CONSTRUCTOR_CALL constructor SYNTHESIZED_OVER main.js:13:1;main.js:16:1 +main.js:16:21 FUNCTION_CALL_CALL call EXACT main.js:13:1 +main.js:17:20 METHOD_CALL create LIB_AMBIENT_OK +main.js:23:23 CONSTRUCTOR_CALL constructor SYNTHESIZED_OK +main.js:25:20 CONSTRUCTOR_CALL constructor SYNTHESIZED_OK +main.js:31:3 CONSTRUCTOR_CALL Select SYNTHESIZED_OK +main.js:31:3 METHOD_CALL clone EXACT main.js:23:3 +main.js:32:3 CONSTRUCTOR_CALL Query SYNTHESIZED_OK +main.js:32:3 METHOD_CALL clone EXACT main.js:23:3 +main.js:33:3 CONSTRUCTOR_CALL Select SYNTHESIZED_OK +main.js:33:3 METHOD_CALL fork EXACT main.js:25:3 +main.js:34:3 CONSTRUCTOR_CALL Sub SYNTHESIZED_OK +main.js:34:3 METHOD_CALL clone EXACT main.js:5:3 +main.js:35:3 CONSTRUCTOR_CALL Other SYNTHESIZED_OK +main.js:35:3 METHOD_CALL clone EXACT main.js:5:3 +main.js:36:3 CONSTRUCTOR_CALL Sub SYNTHESIZED_OK +main.js:36:3 METHOD_CALL clone EXACT main.js:5:3 +main.js:37:3 CONSTRUCTOR_CALL Base SYNTHESIZED_OK +main.js:37:3 METHOD_CALL clone EXACT main.js:5:3 +main.js:39:3 CONSTRUCTOR_CALL Sub SYNTHESIZED_OK +main.js:39:3 METHOD_CALL copy EXACT main.js:6:3 +main.js:39:3 METHOD_CALL run EXACT main.js:7:3 +main.js:40:3 CONSTRUCTOR_CALL Circle EXACT main.js:16:1 +main.js:41:3 CONSTRUCTOR_CALL Shape EXACT main.js:13:1 +main.js:41:3 METHOD_CALL dup EXACT main.js:14:23 +main.js:43:1 FUNCTION_CALL main EXACT main.js:30:1 +main.js:5:20 CONSTRUCTOR_CALL constructor SYNTHESIZED_OK +main.js:6:19 CONSTRUCTOR_CALL Base SYNTHESIZED_OK +# defects: 0 diff --git a/graph/test/typescript/cases/32-decorator-application/src/svc.ts b/graph/test/typescript/cases/32-decorator-application/src/svc.ts index 36d8c88e4..37601b24c 100644 --- a/graph/test/typescript/cases/32-decorator-application/src/svc.ts +++ b/graph/test/typescript/cases/32-decorator-application/src/svc.ts @@ -1,12 +1,10 @@ -// TWO FORMS OF THE SAME CONSTRUCT, and the parser treats them differently. +// TWO FORMS OF THE SAME CONSTRUCT, and both are calls. // // A decorator is a function the runtime invokes with (target, key, descriptor), and the -// compiler resolves the application in both forms. The parser emits a ts_call_site for -// the FACTORY form only; the bare form gets a ts_decorator row with kind = MARKER and no -// call site at all, so the application is absent from the IR and cannot be scored. See -// parser#82 — re-priced there on 15 sites inside one repository, which is not what it -// costs on a codebase that decorates most public methods (191 sites, 89% of one -// project's whole conservation loss). +// compiler resolves the application in both forms. The FACTORY form is a CallExpression +// and always had a call site; the BARE form is only a name, and until #233 it had no call +// site at all, so the application was absent from the IR. On a codebase that decorates +// most public methods that was 191 sites, 89% of one project's whole conservation loss. export function guarded(_t: unknown, _k: string, d: PropertyDescriptor): PropertyDescriptor { return d; } @@ -15,16 +13,43 @@ export function timed(): MethodDecorator { return (_t, _k, d) => d; } +export function sealed(ctor: Function): void { + Object.seal(ctor); +} + +export class Guards { + static disposed(_t: unknown, _k: string, d: PropertyDescriptor): PropertyDescriptor { + return d; + } +} + +@sealed export class Svc { - // BARE — the gap. No call site of any kind is emitted for this. + // BARE, a name: the application is a call to `guarded`. @guarded a(): string { return "a"; } - // THE CONTROL: the factory form, which does emit a call site for the factory call. + // THE CONTROL: the factory form. ONE call site, for `timed()`; the name inside it is + // that call's callee and must not become a second call. @timed() b(): string { return "b"; } + + // BARE, a member of a class: `Guards.disposed` is called, not read. + @Guards.disposed + c(): string { + return "c"; + } + + // BARE, parenthesised: the parentheses are punctuation, still one call. + @(guarded) + d(): string { + return "d"; + } } + +// THE CONTROL: the same name outside a decorator is a reference, not a call. +export const alias = guarded; diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts new file mode 100644 index 000000000..a26f70aff --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/client/axios.d.ts @@ -0,0 +1,13 @@ +// The client package, as its types describe it. The rules key on the import's +// specifier, so this stands in for the package without changing what is read. +declare module 'axios' { + interface HttpInstance { + get(url: string, config?: unknown): Promise; + post(url: string, data?: unknown, config?: unknown): Promise; + } + interface HttpStatic extends HttpInstance { + create(config: { baseURL?: string }): HttpInstance; + } + const axios: HttpStatic; + export default axios; +} diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts new file mode 100644 index 000000000..1b5022775 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/client/client.ts @@ -0,0 +1,34 @@ +// The client end. None of these calls the server: the only thing tying a send to its +// handler is the route both ends spell, so every edge below is a remote_edge, never a call. +import axios from 'axios'; +import { ORDERS } from '../shared/paths'; +import { config } from '../shared/config'; + +// literal, built by a template: GET /api/orders/{} -> getOrder (a placeholder stood in for :id) +export async function fetchOrder(id: string) { return fetch(`/api/orders/${id}`); } + +// constant: POST /api/orders -> createOrder, and NOT listOrders, which serves GET +export async function placeOrder(body: unknown) { return axios.post(ORDERS, body); } + +// constant, GET by default: -> listOrders only +export async function listAll() { return fetch(ORDERS); } + +// configured base address plus a segment: GET /api/reports/{} -> ReportsController.daily +export async function dailyReport(day: string) { return axios.get(`${config.reportsUrl}/${day}`); } + +// verb from the init object, path by concatenation: DELETE /api/orders/{} -> the inline handler +export async function cancelOrder(id: string) { return fetch('/api/orders/' + id, { method: 'DELETE' }); } + +// an instance with a base URL: GET /api/invoices/{} -> getInvoice +const billing = axios.create({ baseURL: 'http://billing:9000/api/invoices' }); +export async function invoice(id: string) { return billing.get(`/${id}`); } + +// served by nothing here: remote_unserved +export async function audit() { return axios.get('/api/audit'); } + +// a destination that cannot be read: remote_undetermined +export async function anywhere(url: string) { return fetch(url); } + +// CONTROL: a Map keyed by a path-shaped string is not a send +const cache = new Map(); +export function cached(): string | undefined { return cache.get('/api/orders'); } diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts new file mode 100644 index 000000000..9dead2f24 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/server/orders.ts @@ -0,0 +1,26 @@ +// The server end, in the call form every router in this ecosystem shares: +// `app.(path, handler)`. The path is a literal, or a constant from another module. +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; +declare const router: { + delete(path: string, h: unknown): void; +}; + +export function getOrder(): string { return 'one'; } +export function listOrders(): string { return 'all'; } +export function createOrder(): string { return 'created'; } +export function cancelWork(): string { return 'cancelled'; } +export function getInvoice(): string { return 'invoice'; } + +// literal path with a route parameter +app.get('/api/orders/:id', getOrder); +// the same constant under two verbs: a POST must reach createOrder and never listOrders +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/invoices/:id', getInvoice); +// an inline handler +router.delete('/api/orders/:id', () => cancelWork()); diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts new file mode 100644 index 000000000..d5d694c18 --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/server/reports.controller.ts @@ -0,0 +1,16 @@ +// The server end in the decorator form: the class route is a prefix of every method route. +function Controller(_path: string) { return (_t: Function): void => {}; } +function Get(_path: string) { return (_t: object, _k: string, _d: PropertyDescriptor): void => {}; } +function Post(_path: string) { return (_t: object, _k: string, _d: PropertyDescriptor): void => {}; } + +@Controller('api/reports') +export class ReportsController { + @Get(':day') + daily(): string { return 'daily'; } + + @Post('rebuild') + rebuild(): string { return 'rebuilt'; } + + // no route decorator: served by nothing + helper(): string { return 'helper'; } +} diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts new file mode 100644 index 000000000..573fb5dab --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/config.ts @@ -0,0 +1,6 @@ +// A configured base address: the client reads it off a config object, never as a literal +// at the send. The scheme and host are dropped when the two ends are compared. +export const config = { + reportsUrl: 'http://reports:8080/api/reports', + timeoutMs: 3000, +}; diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts new file mode 100644 index 000000000..658f4229b --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/shared/paths.ts @@ -0,0 +1,2 @@ +// A route spelled once and imported by both ends: the "constant declared elsewhere" shape. +export const ORDERS = '/api/orders'; diff --git a/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json b/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json new file mode 100644 index 000000000..b09665abf --- /dev/null +++ b/graph/test/typescript/cases/78-cross-process-destinations/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "experimentalDecorators": true + } +} diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/app.ts b/graph/test/typescript/cases/79-program-scoped-globals/src/app.ts new file mode 100644 index 000000000..e2237198c --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/app.ts @@ -0,0 +1,15 @@ +// Production code. `fixture/` is a SEPARATE program — its own tsconfig — that redeclares +// the standard library for a test. Its globals are not in scope here, so each call below +// has exactly one target, in ./globals.d.ts, and the callback passed in fixture/use.ts is +// not reached from `labels`. +export class Item { + label(): string { return "item"; } +} + +export function labels(items: Item[]): string[] { + return items.map((i) => i.label()); +} + +export function describe(item: Item): string { + return item.toString(); +} diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/globals.d.ts b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/globals.d.ts new file mode 100644 index 000000000..feeeacde1 --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/globals.d.ts @@ -0,0 +1,19 @@ +// A TEST FIXTURE's own standard library: a separate program (./tsconfig.json), so these +// globals belong to it alone. The parameter names differ from ../globals.d.ts on purpose, +// and `toString` takes a parameter, so the two `Array.map` and `Object.toString` +// declarations are told apart in the goldens. +interface Array { + map(each: (item: T) => U): U[]; +} +interface ReadonlyArray { + map(each: (item: T) => U): U[]; +} +interface Object { toString(radix?: number): string; } +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/tsconfig.json b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/tsconfig.json new file mode 100644 index 000000000..c901fced9 --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "noLib": true, "target": "es2022", "module": "commonjs" } } diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/use.ts b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/use.ts new file mode 100644 index 000000000..af7b2f5ec --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/fixture/use.ts @@ -0,0 +1,12 @@ +// CONTROL: inside the fixture program its own globals still resolve. `rows.map` must keep +// its edge to this program's `Array.map` (the one whose parameter is `each`). It also +// keeps the enclosing program's: a global declared under an ANCESTOR tsconfig stays +// visible below it, which is how a monorepo shares one `types/global.d.ts` — the IR does +// not say which files a nested program includes, so that direction stays a superset. +export class Row { + key(): string { return "row"; } +} + +export function keys(rows: Row[]): string[] { + return rows.map((r) => r.key()); +} diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/globals.d.ts b/graph/test/typescript/cases/79-program-scoped-globals/src/globals.d.ts new file mode 100644 index 000000000..9e6128237 --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/globals.d.ts @@ -0,0 +1,17 @@ +// The PRODUCTION program's standard library, under `noLib` for the reason case 21 gives. +// Every name here is visible to every file this tsconfig governs, and to files below it. +interface Array { + map(fn: (v: T) => U): U[]; +} +interface ReadonlyArray { + map(fn: (v: T) => U): U[]; +} +interface Object { toString(): string; } +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/graph/test/typescript/cases/79-program-scoped-globals/src/tsconfig.json b/graph/test/typescript/cases/79-program-scoped-globals/src/tsconfig.json new file mode 100644 index 000000000..61f1dc822 --- /dev/null +++ b/graph/test/typescript/cases/79-program-scoped-globals/src/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "noLib": true, "target": "es2022", "module": "commonjs" }, "exclude": ["fixture"] } diff --git a/graph/test/typescript/cases/80-generic-return-receiver/lib/container.ts b/graph/test/typescript/cases/80-generic-return-receiver/lib/container.ts new file mode 100644 index 000000000..b50fc584d --- /dev/null +++ b/graph/test/typescript/cases/80-generic-return-receiver/lib/container.ts @@ -0,0 +1,17 @@ +// A dependency-injection container as a library ships it: the service's type is +// the call's own type argument, and nothing else names it. +export class Container { + get(id: symbol): T { + return undefined as unknown as T; + } + bind(id: symbol): Container { + return this; + } +} + +// A generic keyed store, the library-side twin of Map. +export class Registry { + lookup(key: string): V | undefined { + return undefined; + } +} diff --git a/graph/test/typescript/cases/80-generic-return-receiver/src/use.ts b/graph/test/typescript/cases/80-generic-return-receiver/src/use.ts new file mode 100644 index 000000000..4cdb751f9 --- /dev/null +++ b/graph/test/typescript/cases/80-generic-return-receiver/src/use.ts @@ -0,0 +1,88 @@ +// A receiver whose type is what a GENERIC or computed call returns. Each shape is +// statically typed by the compiler, so each `.m()` below has one declared target. + +export interface Warrior { fight(): string; } +export class Ninja implements Warrior { fight(): string { return "ninja"; } } + +export class User { + touch(): string { return "t"; } + save(): string { return "s"; } +} + +export interface Shape { area(): number; } +export class Sq implements Shape { constructor(private s: number) {} area(): number { return this.s * this.s; } } +export class Circ implements Shape { constructor(private r: number) {} area(): number { return 3 * this.r * this.r; } } + +// ── the gap ────────────────────────────────────────────────────────────────── +// `Map` built with `new`, read with `.get(k)!`. +export function recvMapGet(): string { + const m = new Map(); + return m.get("x")!.touch(); +} + +// A function-typed PARAMETER with its own type parameter, called with a written argument. +export function recvGeneric(get: () => T): string { + return get().touch(); +} + +// An index-signature object whose values are factories; the element is CALLED. +const factories: { [k: string]: (n: number) => Shape } = { + sq: (n) => new Sq(n), + circ: (n) => new Circ(n), +}; +export function totalArea(kind: string, n: number): number { + return factories[kind](n).area(); +} + +// The same through a declared Record alias. +const makers: Record Shape> = { sq: (n) => new Sq(n) }; +export function recordArea(kind: string, n: number): number { + return makers[kind](n).area(); +} + +// An inline index signature whose value is NOT a function: the element is a Shape. +const shapes: { [k: string]: Shape } = { sq: new Sq(1) }; +export function indexValue(kind: string): number { + return shapes[kind].area(); +} + +// T bound from the ARGUMENT of a generic function-typed parameter. +export function recvGenericArg(pick: (x: T) => T, u: User): string { + return pick(u).save(); +} + +// ── controls: already typed, must not change ──────────────────────────────── +export function ctlDeclared(): string { + const u: User = JSON.parse("{}"); + return u.save(); +} +export function ctlCast(): string { + const u = JSON.parse("{}") as User; + return u.save(); +} +// ── controls: nothing names the type, so these must STAY unresolved ───────── +// The written type argument is `any`: T is untyped, and `.touch()` is a name only. +export function ctlGenericUnbound(get: () => T): string { + return get().touch(); +} +// An index signature over `any`: the element is untyped. +const anyBag: { [k: string]: any } = {}; +export function ctlAnyIndex(kind: string): number { + return anyBag[kind].area(); +} + +// ── client -> library ──────────────────────────────────────────────────────── +import { Container, Registry } from "../lib/container"; + +const container = new Container(); +const WARRIOR = Symbol.for("Warrior"); +container.bind(WARRIOR); + +export function run(): string { + const w = container.get(WARRIOR); + return w.fight(); +} + +export function viaRegistry(r: Registry): string { + return r.lookup("x")!.touch(); +} diff --git a/graph/test/typescript/cases/80-method-passed-by-reference/src/globals.d.ts b/graph/test/typescript/cases/80-method-passed-by-reference/src/globals.d.ts new file mode 100644 index 000000000..b76b4fc98 --- /dev/null +++ b/graph/test/typescript/cases/80-method-passed-by-reference/src/globals.d.ts @@ -0,0 +1,19 @@ +// The slice of the standard library this case hands methods to. Declared here, as client code, so the +// client-only pass sees `forEach`, `map` and `bind` as what they are to the project: signatures with no body. +interface Array { + map(fn: (v: T) => U, thisArg?: unknown): U[]; + forEach(fn: (v: T) => void, thisArg?: unknown): void; +} +interface Object {} +interface Boolean {} +interface Number {} +interface String {} +interface Function { + bind(thisArg: unknown, ...args: unknown[]): any; + call(thisArg: unknown, ...args: unknown[]): any; +} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} +declare function setTimeout(cb: () => void, ms?: number): number; diff --git a/graph/test/typescript/cases/80-method-passed-by-reference/src/tsconfig.json b/graph/test/typescript/cases/80-method-passed-by-reference/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/80-method-passed-by-reference/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/cases/80-method-passed-by-reference/src/widget.ts b/graph/test/typescript/cases/80-method-passed-by-reference/src/widget.ts new file mode 100644 index 000000000..acb892a6f --- /dev/null +++ b/graph/test/typescript/cases/80-method-passed-by-reference/src/widget.ts @@ -0,0 +1,46 @@ +// A METHOD PASSED BY REFERENCE. `this.m`, `this.m.bind(this)`, `xs.forEach(this.m, this)` and a listener +// registered on a typed receiver hand the method over without calling it, exactly as `xs.map(double)` hands a +// free function over, so the method that writes the hand-off reaches the method it hands. +export interface Target { + addEventListener(type: string, fn: () => void): void; +} + +export class Widget { + private scale = 2; + handle(n: number): number { return n * this.scale; } + onClick(): number { return 1; } + onHover(): number { return 2; } + onTap(): number { return 4; } + later(): () => void { return () => {}; } + label = 'w'; + + start(xs: number[]): number[] { + xs.forEach(this.handle.bind(this)); + return xs.map(this.handle, this); + } + + wire(el: Target): void { + el.addEventListener('click', this.onClick); + setTimeout(this.onHover.bind(this)); + } + + // a method of ANOTHER instance, handed over through a typed receiver + relay(other: Widget, xs: number[]): void { + xs.forEach(other.handle, other); + } + + // CONTROL: an arrow that CALLS the method is the arrow's call, as before — the passer registers the arrow only + tap(el: Target): void { + el.addEventListener('tap', () => this.onTap()); + } + + // CONTROL: `call` RUNS the method here and hands over what it returns (the arrow `later` returns, not + // `later`), a data field is not a function, and a getter is READ where it is written: its value is handed over + now(xs: number[]): void { + setTimeout(this.later.call(this)); + xs.forEach(this.label as any); + xs.forEach(this.current); + } + + get current(): (n: number) => void { return () => {}; } +} diff --git a/graph/test/typescript/cases/80-object-literal-member-receivers/src/api.ts b/graph/test/typescript/cases/80-object-literal-member-receivers/src/api.ts new file mode 100644 index 000000000..bf38850a6 --- /dev/null +++ b/graph/test/typescript/cases/80-object-literal-member-receivers/src/api.ts @@ -0,0 +1,31 @@ +// An API module: a const object literal of callables, exported and called from elsewhere. +export function listUsers(): string[] { return []; } +export function getUserById(id: string): string { return id; } + +export const api = { + users: { + list() { return listUsers(); }, + get: (id: string) => getUserById(id), + byName: function (n: string) { return n; }, + }, + ping() { return 'pong'; }, + remove: (id: string) => id, + find: getUserById, +}; + +// A factory returning a literal: the call's value is that literal. +export function makeStore(start: number) { + let n = start; + return { + inc() { n += 1; return n; }, + reset: () => { n = 0; }, + }; +} + +// CONTROL: a literal bound to an ANNOTATED const. The compiler resolves a call through it +// to the interface's member signature, so the literal's own method must not be added. +export interface Greeter { greet(n: string): string } +export const greeter: Greeter = { greet(n: string) { return n; } }; + +// CONTROL: a factory with a declared return type — the annotation is the answer. +export function makeGreeter(): Greeter { return { greet: (n: string) => n.trim() }; } diff --git a/graph/test/typescript/cases/80-object-literal-member-receivers/src/local.ts b/graph/test/typescript/cases/80-object-literal-member-receivers/src/local.ts new file mode 100644 index 000000000..2ad034104 --- /dev/null +++ b/graph/test/typescript/cases/80-object-literal-member-receivers/src/local.ts @@ -0,0 +1,12 @@ +function helper(): number { return 1; } + +// same file, unexported: nested literal and arrow property +const handlers = { + jobs: { run: () => helper(), stop() { return 0; } }, +}; +export function drive() { handlers.jobs.run(); return handlers.jobs.stop(); } + +// control: only a property whose VALUE is a literal is followed. `cfg.name` is a string, so +// `cfg.name.trim()` is String#trim, never the literal's own `trim` one level up. +const cfg = { name: ' x ', trim() { return 1; } }; +export function trimName() { return cfg.name.trim(); } diff --git a/graph/test/typescript/cases/80-object-literal-member-receivers/src/tsconfig.json b/graph/test/typescript/cases/80-object-literal-member-receivers/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/80-object-literal-member-receivers/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/cases/80-object-literal-member-receivers/src/use.ts b/graph/test/typescript/cases/80-object-literal-member-receivers/src/use.ts new file mode 100644 index 000000000..8e506ed32 --- /dev/null +++ b/graph/test/typescript/cases/80-object-literal-member-receivers/src/use.ts @@ -0,0 +1,20 @@ +import { api, makeStore, greeter, makeGreeter } from './api'; + +// same-file and imported: a method, an arrow property, a function-expression property +export function callNested() { api.users.list(); api.users.byName('x'); return api.users.get('1'); } +export function callTop() { api.ping(); api.find('2'); return api.remove('1'); } + +// a factory's result, held in a const and called directly +export function callStore() { + const s = makeStore(1); + s.inc(); + makeStore(2).reset(); + return s; +} + +// controls: annotated receivers keep their declared member +export function callGreeter() { greeter.greet('a'); return makeGreeter().greet('b'); } + +// control: a literal with the same member names that is never called +const unrelated = { list() { return 1; }, inc() { return 2; } }; +export function keepUnrelated() { return unrelated; } diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.envelope b/graph/test/typescript/expected/08-function-values-and-callbacks.envelope index 35412fef8..b629f2f6b 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.envelope +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.envelope @@ -1,5 +1,7 @@ value values#@4:25 -> values#@6 value values#@4:25 -> values#@7 +value values#@4:25 -> values#Pipeline.tag value_call values#Pipeline.run -> values#@6 value_call values#apply -> values#@6 value_call values#apply -> values#@7 +value_call values#drive -> values#Pipeline.tag diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.envelope b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.envelope index b3f60e377..c3b500e6a 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.envelope +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.envelope @@ -1,6 +1,8 @@ value lib:hooks#@1:20 -> values#@37 value values#@4:25 -> values#@6 value values#@4:25 -> values#@7 +value values#@4:25 -> values#Pipeline.tag value_call values#Pipeline.run -> values#@6 value_call values#apply -> values#@6 value_call values#apply -> values#@7 +value_call values#drive -> values#Pipeline.tag diff --git a/graph/test/typescript/expected/13-receiver-forms.edges b/graph/test/typescript/expected/13-receiver-forms.edges index a04966b62..5500c94cf 100644 --- a/graph/test/typescript/expected/13-receiver-forms.edges +++ b/graph/test/typescript/expected/13-receiver-forms.edges @@ -20,6 +20,7 @@ known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L22 -> Node#vis known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L23 -> Holder#make() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L23 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L24 -> Node#visit() +known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L25 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L26 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L27 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L28 -> Node#visit() diff --git a/graph/test/typescript/expected/13-receiver-forms.lib.edges b/graph/test/typescript/expected/13-receiver-forms.lib.edges index 974c87f45..f2ad957a0 100644 --- a/graph/test/typescript/expected/13-receiver-forms.lib.edges +++ b/graph/test/typescript/expected/13-receiver-forms.lib.edges @@ -21,6 +21,7 @@ known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L22 -> Node#vis known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L23 -> Holder#make() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L23 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L24 -> Node#visit() +known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L25 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L26 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L27 -> Node#visit() known_edge METHOD_CALL receivers#drive(Holder,Node | undefined) @L28 -> Node#visit() diff --git a/graph/test/typescript/expected/21-callable-function-members.envelope b/graph/test/typescript/expected/21-callable-function-members.envelope index e914caf7d..00ae5de16 100644 --- a/graph/test/typescript/expected/21-callable-function-members.envelope +++ b/graph/test/typescript/expected/21-callable-function-members.envelope @@ -1,3 +1,5 @@ nominal globals#Function.apply -> globals#CallableFunction.apply nominal globals#Function.bind -> globals#CallableFunction.bind nominal globals#Function.call -> globals#CallableFunction.call +value_call strict-bind#viaArrow -> strict-bind# +value_call strict-bind#viaDeclaredFunction -> strict-bind#localFormat diff --git a/graph/test/typescript/expected/22-loose-bind-call-apply.envelope b/graph/test/typescript/expected/22-loose-bind-call-apply.envelope index e914caf7d..858625e64 100644 --- a/graph/test/typescript/expected/22-loose-bind-call-apply.envelope +++ b/graph/test/typescript/expected/22-loose-bind-call-apply.envelope @@ -1,3 +1,5 @@ nominal globals#Function.apply -> globals#CallableFunction.apply nominal globals#Function.bind -> globals#CallableFunction.bind nominal globals#Function.call -> globals#CallableFunction.call +value_call loose-bind#viaArrow -> loose-bind# +value_call loose-bind#viaDeclaredFunction -> loose-bind#localFormat diff --git a/graph/test/typescript/expected/32-decorator-application.edges b/graph/test/typescript/expected/32-decorator-application.edges index a536ed7e7..b1a896cc9 100644 --- a/graph/test/typescript/expected/32-decorator-application.edges +++ b/graph/test/typescript/expected/32-decorator-application.edges @@ -1 +1,6 @@ -known_edge DECORATOR_CALL Svc#b() @L26 -> svc#timed() +ambiguous_unknown METHOD_CALL svc#sealed(Function) @L17 -> - +known_edge DECORATOR_CALL Svc#a() @L29 -> svc#guarded(unknown,string,PropertyDescriptor) +known_edge DECORATOR_CALL Svc#b() @L36 -> svc#timed() +known_edge DECORATOR_CALL Svc#c() @L42 -> Guards#static disposed(unknown,string,PropertyDescriptor) +known_edge DECORATOR_CALL Svc#d() @L48 -> svc#guarded(unknown,string,PropertyDescriptor) +known_edge DECORATOR_CALL svc#() @L26 -> svc#sealed(Function) diff --git a/graph/test/typescript/expected/32-decorator-application.entries b/graph/test/typescript/expected/32-decorator-application.entries new file mode 100644 index 000000000..6e1c6d4f1 --- /dev/null +++ b/graph/test/typescript/expected/32-decorator-application.entries @@ -0,0 +1,5 @@ +── entry_point (4) ── + exported_from_entry_module svc#guarded svc.ts:8 + exported_from_entry_module svc#sealed svc.ts:16 + exported_from_entry_module svc#timed svc.ts:12 + unimported_module svc# svc.ts:1 diff --git a/graph/test/typescript/expected/32-decorator-application.known-missing b/graph/test/typescript/expected/32-decorator-application.known-missing deleted file mode 100644 index 037af29f1..000000000 --- a/graph/test/typescript/expected/32-decorator-application.known-missing +++ /dev/null @@ -1,19 +0,0 @@ -# Accepted gap for 32-decorator-application. A NEW missing edge fails the suite; a line -# here that STARTS working also fails, so the debt cannot rot. -# -# PARSER-BLOCKED, and the engine cannot close it. A bare `@guarded` gets a ts_decorator -# row with kind = MARKER and NO ts_call_site, so the application is absent from the IR -# entirely. The engine has no site to attach an edge to, and score.py joins the oracle to -# IR call sites by position — so the compiler's row becomes CONSERVATION LOSS and leaves -# the denominator rather than counting as a miss. Nothing in the rules can recover that; -# it needs a call site emitted. parser#82, and #233 for the measurement. -# -# The FACTORY form beside it is the control and resolves: `@timed()` does emit a call -# site for the factory call, which is what makes this attributable to the bare form and -# not to decorators in general. -# -# This case is also the first in the suite to use a decorator at all. Until #233 the case -# oracle's node list omitted `ts.isDecorator` while the project oracle emitted -# DECORATOR_CALL, so the per-case suite was green on decorators whatever the engine did. -# Both sides now say the same thing, which is why this line can exist. -Svc#a() -> svc#guarded(unknown,string,PropertyDescriptor) diff --git a/graph/test/typescript/expected/32-decorator-application.oracle b/graph/test/typescript/expected/32-decorator-application.oracle index a955086d4..6c4bb5cbb 100644 --- a/graph/test/typescript/expected/32-decorator-application.oracle +++ b/graph/test/typescript/expected/32-decorator-application.oracle @@ -1,2 +1 @@ -oracle=2 engine=1 agree=1 missing=1 (known 1, NEW 0) extra=0 - known Svc#a() -> svc#guarded(unknown,string,PropertyDescriptor) +oracle=5 engine=5 agree=5 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/32-decorator-application.type-use b/graph/test/typescript/expected/32-decorator-application.type-use index 1f8549a0d..33144774e 100644 --- a/graph/test/typescript/expected/32-decorator-application.type-use +++ b/graph/test/typescript/expected/32-decorator-application.type-use @@ -1,3 +1,5 @@ ambiguous_unknown DECORATOR_TYPE 0 Svc [DECORATOR] -> - +ambiguous_unknown METHOD_PARAM 0 Guards [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 svc [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 Guards [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 svc [METHOD] -> - diff --git a/graph/test/typescript/expected/32-decorator-application.types-oracle b/graph/test/typescript/expected/32-decorator-application.types-oracle index f06517c0b..4a9e4f17a 100644 --- a/graph/test/typescript/expected/32-decorator-application.types-oracle +++ b/graph/test/typescript/expected/32-decorator-application.types-oracle @@ -1,7 +1,7 @@ 32-decorator-application [types] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 5 resolved 0 (0.0%) - tiers ambiguous_unknown=5 - contexts DECORATOR_TYPE=2 METHOD_PARAM=1 METHOD_RETURN=2 - not scored: 5 rows whose target is not a client declaration + sites 11 resolved 0 (0.0%) + tiers ambiguous_unknown=11 + contexts DECORATOR_TYPE=5 METHOD_PARAM=3 METHOD_RETURN=3 + not scored: 11 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/62-field-access-and-type-use.envelope b/graph/test/typescript/expected/62-field-access-and-type-use.envelope new file mode 100644 index 000000000..cf52cd4f3 --- /dev/null +++ b/graph/test/typescript/expected/62-field-access-and-type-use.envelope @@ -0,0 +1 @@ +value members#@46:20 -> members#Box.run diff --git a/graph/test/typescript/expected/64-array-dispatch.edges b/graph/test/typescript/expected/64-array-dispatch.edges index b51c7bf47..24d1959e6 100644 --- a/graph/test/typescript/expected/64-array-dispatch.edges +++ b/graph/test/typescript/expected/64-array-dispatch.edges @@ -1,7 +1,7 @@ ambiguous_unknown FUNCTION_CALL pipeline#runIterated(Node) @L14 -> - -ambiguous_unknown METHOD_CALL pipeline#runByName(Node) @L33 -> - ambiguous_unknown METHOD_CALL pipeline#runIndexed(Node) @L24 -> - known_edge FUNCTION_CALL pipeline#runDirect(Node) @L38 -> steps#narrowRange(Node) known_edge FUNCTION_CALL steps#narrowRange(Node) @L10 -> steps#measure(Node) known_edge FUNCTION_CALL steps#squareRange(Node) @L15 -> steps#measure(Node) known_edge FUNCTION_CALL steps#widenRange(Node) @L5 -> steps#measure(Node) +known_edge METHOD_CALL pipeline#runByName(Node) @L33 -> steps#squareRange(Node) diff --git a/graph/test/typescript/expected/64-array-dispatch.entries b/graph/test/typescript/expected/64-array-dispatch.entries new file mode 100644 index 000000000..4a45121d5 --- /dev/null +++ b/graph/test/typescript/expected/64-array-dispatch.entries @@ -0,0 +1,6 @@ +── entry_point (5) ── + exported_from_entry_module pipeline#runByName pipeline.ts:32 + exported_from_entry_module pipeline#runDirect pipeline.ts:37 + exported_from_entry_module pipeline#runIndexed pipeline.ts:23 + exported_from_entry_module pipeline#runIterated pipeline.ts:13 + unimported_module pipeline# pipeline.ts:1 diff --git a/graph/test/typescript/expected/64-array-dispatch.envelope b/graph/test/typescript/expected/64-array-dispatch.envelope index 9f8a80ae1..01e13841b 100644 --- a/graph/test/typescript/expected/64-array-dispatch.envelope +++ b/graph/test/typescript/expected/64-array-dispatch.envelope @@ -1,3 +1,2 @@ -value_call pipeline#runByName -> steps#squareRange value_call pipeline#runIndexed -> steps#widenRange value_call pipeline#runIterated -> steps#widenRange diff --git a/graph/test/typescript/expected/64-array-dispatch.known-missing b/graph/test/typescript/expected/64-array-dispatch.known-missing index 282e63097..af1928117 100644 --- a/graph/test/typescript/expected/64-array-dispatch.known-missing +++ b/graph/test/typescript/expected/64-array-dispatch.known-missing @@ -20,4 +20,5 @@ # (case 78 measures that). pipeline#runIterated(Node) -> steps#widenRange(Node) pipeline#runIndexed(Node) -> steps#widenRange(Node) -pipeline#runByName(Node) -> steps#squareRange(Node) +# runByName (`byName.square(node)`) resolves: a NAMED property of a literal is one function, +# not an element of a union, so the compiler's answer is exact and now matched. diff --git a/graph/test/typescript/expected/64-array-dispatch.oracle b/graph/test/typescript/expected/64-array-dispatch.oracle index 193e51e57..3f1f76bbc 100644 --- a/graph/test/typescript/expected/64-array-dispatch.oracle +++ b/graph/test/typescript/expected/64-array-dispatch.oracle @@ -1,4 +1,3 @@ -oracle=7 engine=4 agree=4 missing=3 (known 3, NEW 0) extra=0 - known pipeline#runByName(Node) -> steps#squareRange(Node) +oracle=7 engine=5 agree=5 missing=2 (known 2, NEW 0) extra=0 known pipeline#runIndexed(Node) -> steps#widenRange(Node) known pipeline#runIterated(Node) -> steps#widenRange(Node) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.entries b/graph/test/typescript/expected/68-decorator-framework-roots.entries index ba24c10e8..f62f15a82 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.entries +++ b/graph/test/typescript/expected/68-decorator-framework-roots.entries @@ -14,3 +14,7 @@ known_bean container#AuditService.(orders: OrderService) <- OrderService known_bean container#OrderController.(svc: OrderService) <- OrderService unsatisfied container#AuditController.(extra: Unprovided) <- - +── remote_unsent (3) ── + container#AuditController.one http /audit/{} + container#OrderController.add http /orders + container#OrderController.getOne http /orders/{} diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.entries b/graph/test/typescript/expected/69-named-and-inline-handlers.entries index 5f103d877..c9427d114 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.entries +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.entries @@ -3,3 +3,6 @@ http routes#namedHandler routes.ts:28 test routes#namedSpec routes.ts:33 unimported_module routes# routes.ts:1 +── remote_unsent (2) ── + routes# http /inline + routes#namedHandler http /named diff --git a/graph/test/typescript/expected/73-assigned-interface-members.envelope b/graph/test/typescript/expected/73-assigned-interface-members.envelope index 0c3335bd3..f71eac45d 100644 --- a/graph/test/typescript/expected/73-assigned-interface-members.envelope +++ b/graph/test/typescript/expected/73-assigned-interface-members.envelope @@ -2,8 +2,11 @@ value schema#@11:21 -> schema#@24 value schema#Internals.parse -> schema#@15 value schema#Internals.run -> schema#@15 value schema#Internals.run -> schema#@19 +value schema#Internals.run -> schema#Internals.parse value_call schema#@19 -> schema#@15 value_call schema#@24 -> schema#@15 value_call schema#@24 -> schema#@19 +value_call schema#@24 -> schema#Internals.parse value_call schema#parseTyped -> schema#@15 value_call schema#parseTyped -> schema#@19 +value_call schema#parseTyped -> schema#Internals.parse diff --git a/graph/test/typescript/expected/73-assigned-interface-members.type-use b/graph/test/typescript/expected/73-assigned-interface-members.type-use index 86f883b45..260281b6a 100644 --- a/graph/test/typescript/expected/73-assigned-interface-members.type-use +++ b/graph/test/typescript/expected/73-assigned-interface-members.type-use @@ -1,4 +1,3 @@ -ambiguous_unknown METHOD_PARAM 1 Parse [TYPE] -> - known_edge FIELD_TYPE 0 Schema [FIELD] -> Internals known_edge METHOD_PARAM 0 schema [METHOD_PARAM] -> Schema known_edge METHOD_TYPE_PARAM_BOUND 0 - [TYPE_PARAMETER] -> Schema diff --git a/graph/test/typescript/expected/73-assigned-interface-members.types-oracle b/graph/test/typescript/expected/73-assigned-interface-members.types-oracle index 16e28e127..b75276698 100644 --- a/graph/test/typescript/expected/73-assigned-interface-members.types-oracle +++ b/graph/test/typescript/expected/73-assigned-interface-members.types-oracle @@ -1,8 +1,8 @@ 73-assigned-interface-members [types] precision 1.0000 (3 correct, 0 wrong) recall 0.7500 (3 of 4 the compiler resolved) - sites 6 resolved 5 (83.3%) - tiers ambiguous_unknown=1 known_edge=5 - contexts FIELD_TYPE=1 METHOD_PARAM=3 METHOD_TYPE_PARAM_BOUND=1 VARIABLE_TYPE=1 - not scored: 2 rows whose target is not a client declaration + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + contexts FIELD_TYPE=1 METHOD_PARAM=2 METHOD_TYPE_PARAM_BOUND=1 VARIABLE_TYPE=1 + not scored: 1 rows whose target is not a client declaration MISSING Parse USES Schema diff --git a/graph/test/typescript/expected/74-jsx-component-forms.edges b/graph/test/typescript/expected/74-jsx-component-forms.edges index 887e1bbaf..8a9a7700b 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.edges +++ b/graph/test/typescript/expected/74-jsx-component-forms.edges @@ -5,7 +5,6 @@ ambient_terminal FUNCTION_CALL parts#() @L73 -> parts#makeFactory(( ambiguous_unknown FUNCTION_CALL parts#Badge(?) @L44 -> - ambiguous_unknown FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 -> - ambiguous_unknown FUNCTION_CALL settings#Settings() @L6 -> - -ambiguous_unknown METHOD_CALL app#App() @L15 -> - ambiguous_unknown METHOD_CALL parts#formatName(string) @L25 -> - callback_registered FUNCTION_CALL app#() @L4 -> app#() callback_registered FUNCTION_CALL parts#() @L33 -> parts#UserCard({ id: string }) @@ -19,6 +18,7 @@ known_edge FUNCTION_CALL settings#Settings() @L6 -> settings#saveSettings() known_edge JSX_COMPONENT_CALL app#App() @L12 -> parts#Avatar({ src: string }) known_edge JSX_COMPONENT_CALL app#App() @L13 -> Panel#({ title: string }) known_edge JSX_COMPONENT_CALL app#App() @L14 -> settings#Settings() +known_edge METHOD_CALL app#App() @L15 -> parts#Chip({ text: string }) multi_inferred JSX_COMPONENT_CALL app#App() @L10 -> parts#(T) multi_inferred JSX_COMPONENT_CALL app#App() @L10 -> parts#FieldImpl({ label: string },unknown) multi_inferred JSX_COMPONENT_CALL app#App() @L11 -> parts#(T) diff --git a/graph/test/typescript/expected/74-jsx-component-forms.known-missing b/graph/test/typescript/expected/74-jsx-component-forms.known-missing index 58e80c25a..b62f79e35 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.known-missing +++ b/graph/test/typescript/expected/74-jsx-component-forms.known-missing @@ -1,9 +1,6 @@ # Accepted gaps for 74-jsx-component-forms — each line is an edge the TypeScript compiler # resolves and this engine does not. A NEW missing edge fails the suite; # a line here that STARTS working also fails, so the debt cannot rot. -# `` — a member of an object literal named as a tag: the same receiver shape -# as `ui.Chip({...})`, which the member rules do not resolve either. -app#App() -> parts#Chip({ text: string }) # CONTROL, not a JSX gap: `factory()` calls the product a declared factory returns, a # bodiless function type. Argument 0 of makeFactory must NOT stand in for it. app#useFactory() -> parts#() diff --git a/graph/test/typescript/expected/74-jsx-component-forms.oracle b/graph/test/typescript/expected/74-jsx-component-forms.oracle index 8f5df142b..2b0cc0928 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.oracle +++ b/graph/test/typescript/expected/74-jsx-component-forms.oracle @@ -1,5 +1,4 @@ -oracle=12 engine=15 agree=10 missing=2 (known 2, NEW 0) extra=5 - known app#App() -> parts#Chip({ text: string }) +oracle=12 engine=16 agree=11 missing=1 (known 1, NEW 0) extra=5 known app#useFactory() -> parts#() extra app#App() -> parts#Badge(?) extra app#App() -> parts#FieldImpl({ label: string },unknown) diff --git a/graph/test/typescript/expected/78-callable-collections.edges b/graph/test/typescript/expected/78-callable-collections.edges index 531f4a3d4..c157f4312 100644 --- a/graph/test/typescript/expected/78-callable-collections.edges +++ b/graph/test/typescript/expected/78-callable-collections.edges @@ -9,7 +9,6 @@ ambiguous_unknown FUNCTION_CALL pipeline#runValues(Node) @L64 -> - ambiguous_unknown METHOD_CALL pipeline#() @L11 -> - ambiguous_unknown METHOD_CALL pipeline#keysOnly() @L76 -> - ambiguous_unknown METHOD_CALL pipeline#runByKey(Node,'grow' | 'shrink') @L35 -> - -ambiguous_unknown METHOD_CALL pipeline#runByName(Node) @L38 -> - ambiguous_unknown METHOD_CALL pipeline#runForEach(Node) @L43 -> - ambiguous_unknown METHOD_CALL pipeline#runIndexed(Node,number) @L30 -> - ambiguous_unknown METHOD_CALL pipeline#runReduce(Node) @L46 -> - @@ -26,3 +25,4 @@ known_edge FUNCTION_CALL steps#pushed(Node) @L8 -> steps#measure(Node) known_edge FUNCTION_CALL steps#spreadIn(Node) @L9 -> steps#measure(Node) known_edge FUNCTION_CALL steps#square(Node) @L6 -> steps#measure(Node) known_edge FUNCTION_CALL steps#widen(Node) @L4 -> steps#measure(Node) +known_edge METHOD_CALL pipeline#runByName(Node) @L38 -> steps#narrow(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.envelope b/graph/test/typescript/expected/78-callable-collections.envelope index 5a1721884..90d7c9ee5 100644 --- a/graph/test/typescript/expected/78-callable-collections.envelope +++ b/graph/test/typescript/expected/78-callable-collections.envelope @@ -4,7 +4,6 @@ value_call pipeline#@43 -> steps#widen value_call pipeline#@46 -> steps#square value_call pipeline#runByKey -> steps#narrow value_call pipeline#runByKey -> steps#widen -value_call pipeline#runByName -> steps#narrow value_call pipeline#runFrozen -> steps#square value_call pipeline#runImported -> steps#halve value_call pipeline#runIndexed -> steps#narrow diff --git a/graph/test/typescript/expected/78-callable-collections.known-missing b/graph/test/typescript/expected/78-callable-collections.known-missing index 92411e10e..49c4dca71 100644 --- a/graph/test/typescript/expected/78-callable-collections.known-missing +++ b/graph/test/typescript/expected/78-callable-collections.known-missing @@ -11,7 +11,6 @@ pipeline#(?) -> steps#widen(Node) pipeline#(?,?) -> steps#square(Node) pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) -pipeline#runByName(Node) -> steps#narrow(Node) pipeline#runFrozen(Node) -> steps#square(Node) pipeline#runImported(Node) -> steps#halve(Node) pipeline#runIndexed(Node,number) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.oracle b/graph/test/typescript/expected/78-callable-collections.oracle index 82d64eaae..537343a0a 100644 --- a/graph/test/typescript/expected/78-callable-collections.oracle +++ b/graph/test/typescript/expected/78-callable-collections.oracle @@ -1,8 +1,7 @@ -oracle=19 engine=9 agree=9 missing=10 (known 10, NEW 0) extra=0 +oracle=19 engine=10 agree=10 missing=9 (known 9, NEW 0) extra=0 known pipeline#(?) -> steps#widen(Node) known pipeline#(?,?) -> steps#square(Node) known pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) - known pipeline#runByName(Node) -> steps#narrow(Node) known pipeline#runFrozen(Node) -> steps#square(Node) known pipeline#runImported(Node) -> steps#halve(Node) known pipeline#runIndexed(Node,number) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.edges b/graph/test/typescript/expected/78-cross-process-destinations.edges new file mode 100644 index 000000000..3f6b75159 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.edges @@ -0,0 +1,25 @@ +ambiguous_unknown CONSTRUCTOR_CALL client/client#() @L33 -> - +ambiguous_unknown FUNCTION_CALL client/client#anywhere(string) @L30 -> - +ambiguous_unknown FUNCTION_CALL client/client#cancelOrder(string) @L20 -> - +ambiguous_unknown FUNCTION_CALL client/client#fetchOrder(string) @L8 -> - +ambiguous_unknown FUNCTION_CALL client/client#listAll() @L14 -> - +ambiguous_unknown METHOD_CALL client/client#cached() @L34 -> - +callback_registered METHOD_CALL server/orders#() @L20 -> server/orders#getOrder() +callback_registered METHOD_CALL server/orders#() @L22 -> server/orders#listOrders() +callback_registered METHOD_CALL server/orders#() @L23 -> server/orders#createOrder() +callback_registered METHOD_CALL server/orders#() @L24 -> server/orders#getInvoice() +callback_registered METHOD_CALL server/orders#() @L26 -> server/orders#() +known_edge DECORATOR_CALL ReportsController#daily() @L8 -> server/reports.controller#Get(string) +known_edge DECORATOR_CALL ReportsController#rebuild() @L11 -> server/reports.controller#Post(string) +known_edge DECORATOR_CALL server/reports.controller#() @L6 -> server/reports.controller#Controller(string) +known_edge FUNCTION_CALL server/orders#() @L26 -> server/orders#cancelWork() +known_edge METHOD_CALL client/client#() @L23 -> HttpStatic#create({ baseURL?: string }) +known_edge METHOD_CALL client/client#audit() @L27 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#dailyReport(string) @L17 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#invoice(string) @L24 -> HttpInstance#get(string,unknown) +known_edge METHOD_CALL client/client#placeOrder(unknown) @L11 -> HttpInstance#post(string,unknown,unknown) +known_edge METHOD_CALL server/orders#() @L20 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L22 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L23 -> server/orders#post(string,unknown) +known_edge METHOD_CALL server/orders#() @L24 -> server/orders#get(string,unknown) +known_edge METHOD_CALL server/orders#() @L26 -> server/orders#delete(string,unknown) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.entries b/graph/test/typescript/expected/78-cross-process-destinations.entries new file mode 100644 index 000000000..21d5f6de3 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.entries @@ -0,0 +1,40 @@ +── entry_point (26) ── + bean_ctor server/reports.controller#ReportsController. reports.controller.ts:6 + exported_from_entry_module client/client#anywhere client.ts:30 + exported_from_entry_module client/client#audit client.ts:27 + exported_from_entry_module client/client#cached client.ts:34 + exported_from_entry_module client/client#cancelOrder client.ts:20 + exported_from_entry_module client/client#dailyReport client.ts:17 + exported_from_entry_module client/client#fetchOrder client.ts:8 + exported_from_entry_module client/client#invoice client.ts:24 + exported_from_entry_module client/client#listAll client.ts:14 + exported_from_entry_module client/client#placeOrder client.ts:11 + exported_from_entry_module server/orders#cancelWork orders.ts:16 + exported_from_entry_module server/orders#createOrder orders.ts:15 + exported_from_entry_module server/orders#getInvoice orders.ts:17 + exported_from_entry_module server/orders#getOrder orders.ts:13 + exported_from_entry_module server/orders#listOrders orders.ts:14 + http server/orders# orders.ts:26 + http server/orders#createOrder orders.ts:15 + http server/orders#getInvoice orders.ts:17 + http server/orders#getOrder orders.ts:13 + http server/orders#listOrders orders.ts:14 + http server/reports.controller#ReportsController.daily reports.controller.ts:8 + http server/reports.controller#ReportsController.rebuild reports.controller.ts:11 + unimported_module client/axios# axios.d.ts:1 + unimported_module client/client# client.ts:1 + unimported_module server/orders# orders.ts:1 + unimported_module server/reports.controller# reports.controller.ts:1 +── remote_edge (6) ── + client/client#cancelOrder server/orders# http /api/orders/{} exact + client/client#dailyReport server/reports.controller#ReportsController.daily http /api/reports/{} exact + client/client#fetchOrder server/orders#getOrder http /api/orders/{} exact + client/client#invoice server/orders#getInvoice http /api/invoices/{} exact + client/client#listAll server/orders#listOrders http /api/orders exact + client/client#placeOrder server/orders#createOrder http /api/orders exact +── remote_unserved (1) ── + client/client#audit http /api/audit +── remote_unsent (1) ── + server/reports.controller#ReportsController.rebuild http /api/reports/rebuild +── remote_undetermined (1) ── + client/client#anywhere http unresolved_destination diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields new file mode 100644 index 000000000..4fea0459e --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -0,0 +1 @@ +ambiguous_unknown read client/client#dailyReport(string) -> - diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle new file mode 100644 index 000000000..bd6298b8a --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle @@ -0,0 +1,8 @@ +78-cross-process-destinations [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 1 the compiler resolved) + sites 1 resolved 0 (0.0%) + tiers ambiguous_unknown=1 + access read=1 + not scored: 1 rows whose target is not a client declaration + MISSING client/client#dailyReport(string) READ shared/config#reportsUrl diff --git a/graph/test/typescript/expected/78-cross-process-destinations.known-missing b/graph/test/typescript/expected/78-cross-process-destinations.known-missing new file mode 100644 index 000000000..3b1118ba2 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.known-missing @@ -0,0 +1,11 @@ +# Accepted gaps for 78-cross-process-destinations — each line is an edge the TypeScript +# compiler resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# All three are #233, the gap case 68 records: the function a decorator factory +# returns is applied to the decorated declaration, and that hop has no call site. +# The routes this case tests are read from the decorator's NAME and ARGUMENT through +# annotation_on / annotation_arg, never from the application edge. +ReportsController#daily() -> server/reports.controller#(object,string,PropertyDescriptor) +ReportsController#rebuild() -> server/reports.controller#(object,string,PropertyDescriptor) +server/reports.controller#() -> server/reports.controller#(Function) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.oracle b/graph/test/typescript/expected/78-cross-process-destinations.oracle new file mode 100644 index 000000000..661c0433f --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.oracle @@ -0,0 +1,4 @@ +oracle=15 engine=12 agree=12 missing=3 (known 3, NEW 0) extra=0 + known ReportsController#daily() -> server/reports.controller#(object,string,PropertyDescriptor) + known ReportsController#rebuild() -> server/reports.controller#(object,string,PropertyDescriptor) + known server/reports.controller#() -> server/reports.controller#(Function) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.type-use b/graph/test/typescript/expected/78-cross-process-destinations.type-use new file mode 100644 index 000000000..99f271282 --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.type-use @@ -0,0 +1,7 @@ +ambiguous_unknown DECORATOR_TYPE 0 ReportsController [DECORATOR] -> - +ambiguous_unknown METHOD_PARAM 0 server/reports.controller [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 HttpInstance [METHOD] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 client/client [EXPRESSION] -> - +known_edge METHOD_RETURN 0 HttpStatic [METHOD] -> HttpInstance +known_edge SUPER_TYPE 0 HttpStatic [HERITAGE] -> HttpInstance +known_edge VARIABLE_TYPE 0 client/axios [VARIABLE] -> HttpStatic diff --git a/graph/test/typescript/expected/78-cross-process-destinations.types-oracle b/graph/test/typescript/expected/78-cross-process-destinations.types-oracle new file mode 100644 index 000000000..2bfbe9f8a --- /dev/null +++ b/graph/test/typescript/expected/78-cross-process-destinations.types-oracle @@ -0,0 +1,9 @@ +78-cross-process-destinations [types] + precision 0.5000 (1 correct, 1 wrong) + recall 0.5000 (1 of 2 the compiler resolved) + sites 12 resolved 3 (25.0%) + tiers ambiguous_unknown=9 known_edge=3 + contexts DECORATOR_TYPE=3 METHOD_PARAM=3 METHOD_RETURN=3 OBJECT_CREATION_TYPE=1 SUPER_TYPE=1 VARIABLE_TYPE=1 + not scored: 9 rows whose target is not a client declaration + WRONG client/axios USES HttpStatic + MISSING client/axios.d USES HttpStatic diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use index 342764e7b..9ab1cb6c3 100644 --- a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use @@ -1,5 +1,4 @@ ambiguous_unknown AS_TARGET 0 store [EXPRESSION] -> - -ambiguous_unknown TYPE_ARGUMENT 2 LoggerImpl [TYPE] -> - ambiguous_unknown TYPE_ELEMENT 1 store [METHOD_PARAM] -> - known_edge CONDITIONAL_TRUE 2 Mutate [TYPE] -> Mutate known_edge FIELD_TYPE 0 StoreApi [FIELD] -> SetStateInternal diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle index 2c07f6cbd..2a1980f44 100644 --- a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle @@ -1,7 +1,7 @@ 79-function-typed-parameter-dispatch [types] precision 1.0000 (12 correct, 0 wrong) recall 1.0000 (12 of 12 the compiler resolved) - sites 32 resolved 27 (84.4%) - tiers ambiguous_unknown=5 known_edge=27 - contexts AS_TARGET=1 CONDITIONAL_TRUE=1 FIELD_TYPE=1 METHOD_PARAM=6 METHOD_RETURN=2 TYPE_ARGUMENT=8 TYPE_ELEMENT=11 VARIABLE_TYPE=2 - not scored: 5 rows whose target is not a client declaration + sites 30 resolved 27 (90.0%) + tiers ambiguous_unknown=3 known_edge=27 + contexts AS_TARGET=1 CONDITIONAL_TRUE=1 FIELD_TYPE=1 METHOD_PARAM=6 METHOD_RETURN=2 TYPE_ARGUMENT=6 TYPE_ELEMENT=11 VARIABLE_TYPE=2 + not scored: 3 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-program-scoped-globals.edges b/graph/test/typescript/expected/79-program-scoped-globals.edges new file mode 100644 index 000000000..f3b15c69d --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.edges @@ -0,0 +1,8 @@ +callback_registered METHOD_CALL app#labels(Item[]) @L10 -> app#(?) +callback_registered METHOD_CALL fixture/use#keys(Row[]) @L11 -> fixture/use#(?) +known_edge METHOD_CALL app#(?) @L10 -> Item#label() +known_edge METHOD_CALL app#describe(Item) @L14 -> Object#toString() +known_edge METHOD_CALL app#labels(Item[]) @L10 -> Array#map((v: T) =) +known_edge METHOD_CALL fixture/use#(?) @L11 -> Row#key() +multi_inferred METHOD_CALL fixture/use#keys(Row[]) @L11 -> Array#map((item: T) =) +multi_inferred METHOD_CALL fixture/use#keys(Row[]) @L11 -> Array#map((v: T) =) diff --git a/graph/test/typescript/expected/79-program-scoped-globals.entries b/graph/test/typescript/expected/79-program-scoped-globals.entries new file mode 100644 index 000000000..366143075 --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.entries @@ -0,0 +1,8 @@ +── entry_point (7) ── + exported_from_entry_module app#describe app.ts:13 + exported_from_entry_module app#labels app.ts:9 + exported_from_entry_module fixture/use#keys use.ts:10 + unimported_module app# app.ts:1 + unimported_module fixture/globals# globals.d.ts:1 + unimported_module fixture/use# use.ts:1 + unimported_module globals# globals.d.ts:1 diff --git a/graph/test/typescript/expected/79-program-scoped-globals.fields b/graph/test/typescript/expected/79-program-scoped-globals.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/79-program-scoped-globals.fields-oracle b/graph/test/typescript/expected/79-program-scoped-globals.fields-oracle new file mode 100644 index 000000000..0f7a348b1 --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.fields-oracle @@ -0,0 +1,7 @@ +79-program-scoped-globals [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-program-scoped-globals.oracle b/graph/test/typescript/expected/79-program-scoped-globals.oracle new file mode 100644 index 000000000..c2faaa76f --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.oracle @@ -0,0 +1,2 @@ +oracle=5 engine=6 agree=5 missing=0 (known 0, NEW 0) extra=1 + extra fixture/use#keys(Row[]) -> Array#map((item: T) =) diff --git a/graph/test/typescript/expected/79-program-scoped-globals.type-use b/graph/test/typescript/expected/79-program-scoped-globals.type-use new file mode 100644 index 000000000..a756a42f2 --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.type-use @@ -0,0 +1,5 @@ +known_edge METHOD_PARAM 0 app [METHOD_PARAM] -> Item +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function +known_edge TYPE_ELEMENT 1 app [METHOD_PARAM] -> Item +known_edge TYPE_ELEMENT 1 fixture/use [METHOD_PARAM] -> Row diff --git a/graph/test/typescript/expected/79-program-scoped-globals.types-oracle b/graph/test/typescript/expected/79-program-scoped-globals.types-oracle new file mode 100644 index 000000000..86faf239c --- /dev/null +++ b/graph/test/typescript/expected/79-program-scoped-globals.types-oracle @@ -0,0 +1,7 @@ +79-program-scoped-globals [types] + precision 1.0000 (4 correct, 0 wrong) + recall 1.0000 (4 of 4 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + contexts METHOD_PARAM=1 SUPER_TYPE=4 TYPE_ELEMENT=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-generic-return-receiver.edges b/graph/test/typescript/expected/80-generic-return-receiver.edges new file mode 100644 index 000000000..7978877fe --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.edges @@ -0,0 +1,34 @@ +ambiguous_unknown CONSTRUCTOR_CALL use#() @L77 -> - +ambiguous_unknown CONSTRUCTOR_CALL use#recvMapGet() @L19 -> - +ambiguous_unknown METHOD_CALL use#() @L78 -> - +ambiguous_unknown METHOD_CALL use#() @L79 -> - +ambiguous_unknown METHOD_CALL use#ctlAnyIndex(string) @L71 -> - +ambiguous_unknown METHOD_CALL use#ctlCast() @L60 -> - +ambiguous_unknown METHOD_CALL use#ctlDeclared() @L56 -> - +ambiguous_unknown METHOD_CALL use#ctlGenericUnbound(() =) @L66 -> - +ambiguous_unknown METHOD_CALL use#recvMapGet() @L20 -> - +ambiguous_unknown METHOD_CALL use#run() @L82 -> - +ambiguous_unknown METHOD_CALL use#run() @L83 -> - +ambiguous_unknown METHOD_CALL use#viaRegistry(Registry) @L87 -> - +known_edge CONSTRUCTOR_CALL use#(?) @L30 -> Sq#(number) +known_edge CONSTRUCTOR_CALL use#(?) @L31 -> Circ#(number) +known_edge CONSTRUCTOR_CALL use#(?) @L38 -> Sq#(number) +known_edge CONSTRUCTOR_CALL use#() @L44 -> Sq#(number) +known_edge FUNCTION_CALL use#ctlGenericUnbound(() =) @L66 -> use#() +known_edge FUNCTION_CALL use#recvGeneric(() =) @L25 -> use#() +known_edge FUNCTION_CALL use#recvGenericArg((x: T) =,User) @L51 -> use#(T) +known_edge METHOD_CALL use#ctlCast() @L61 -> User#save() +known_edge METHOD_CALL use#ctlDeclared() @L57 -> User#save() +known_edge METHOD_CALL use#recordArea(string,number) @L40 -> use#(number) +known_edge METHOD_CALL use#recvGeneric(() =) @L25 -> User#touch() +known_edge METHOD_CALL use#recvGenericArg((x: T) =,User) @L51 -> User#save() +known_edge METHOD_CALL use#totalArea(string,number) @L34 -> use#(number) +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Circ#area() +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Shape#area() +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Sq#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Circ#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Shape#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Sq#area() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Circ#area() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Shape#area() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Sq#area() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.entries b/graph/test/typescript/expected/80-generic-return-receiver.entries new file mode 100644 index 000000000..a56bf62ed --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.entries @@ -0,0 +1,14 @@ +── entry_point (13) ── + exported_from_entry_module use#ctlAnyIndex use.ts:70 + exported_from_entry_module use#ctlCast use.ts:59 + exported_from_entry_module use#ctlDeclared use.ts:55 + exported_from_entry_module use#ctlGenericUnbound use.ts:65 + exported_from_entry_module use#indexValue use.ts:45 + exported_from_entry_module use#recordArea use.ts:39 + exported_from_entry_module use#recvGeneric use.ts:24 + exported_from_entry_module use#recvGenericArg use.ts:50 + exported_from_entry_module use#recvMapGet use.ts:18 + exported_from_entry_module use#run use.ts:81 + exported_from_entry_module use#totalArea use.ts:33 + exported_from_entry_module use#viaRegistry use.ts:86 + unimported_module use# use.ts:1 diff --git a/graph/test/typescript/expected/80-generic-return-receiver.envelope b/graph/test/typescript/expected/80-generic-return-receiver.envelope new file mode 100644 index 000000000..aee022b92 --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.envelope @@ -0,0 +1,6 @@ +nominal use#Shape.area -> use#Circ.area +nominal use#Shape.area -> use#Sq.area +nominal use#Warrior.fight -> use#Ninja.fight +value_call use#recordArea -> use#@38 +value_call use#totalArea -> use#@30 +value_call use#totalArea -> use#@31 diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields b/graph/test/typescript/expected/80-generic-return-receiver.fields new file mode 100644 index 000000000..b7d27851d --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields @@ -0,0 +1,2 @@ +known_edge read Circ#area() -> Circ#r +known_edge read Sq#area() -> Sq#s diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle new file mode 100644 index 000000000..f770da18f --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle @@ -0,0 +1,7 @@ +80-generic-return-receiver [fields] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-generic-return-receiver.known-missing b/graph/test/typescript/expected/80-generic-return-receiver.known-missing new file mode 100644 index 000000000..54ca2f05b --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.known-missing @@ -0,0 +1,10 @@ +# Accepted gaps for 80-generic-return-receiver — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# The receiver's type is the return of a LIBRARY generic: with no library there is no +# signature to read it from. run and viaRegistry resolve in the .lib pass. +use#run() -> Warrior#fight() +use#viaRegistry(Registry) -> User#touch() +# `Map` is the standard library's, which no case pass loads; it resolves on a real index +# given the TypeScript lib as a library. +use#recvMapGet() -> User#touch() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.lib.edges b/graph/test/typescript/expected/80-generic-return-receiver.lib.edges new file mode 100644 index 000000000..342e368bd --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.lib.edges @@ -0,0 +1,36 @@ +ambiguous_unknown CONSTRUCTOR_CALL use#recvMapGet() @L19 -> - +ambiguous_unknown METHOD_CALL use#() @L78 -> - +ambiguous_unknown METHOD_CALL use#ctlAnyIndex(string) @L71 -> - +ambiguous_unknown METHOD_CALL use#ctlCast() @L60 -> - +ambiguous_unknown METHOD_CALL use#ctlDeclared() @L56 -> - +ambiguous_unknown METHOD_CALL use#ctlGenericUnbound(() =) @L66 -> - +ambiguous_unknown METHOD_CALL use#recvMapGet() @L20 -> - +boundary_lib CONSTRUCTOR_CALL use#() @L77 -> Container#() +boundary_lib METHOD_CALL use#() @L79 -> Container#bind(symbol) +boundary_lib METHOD_CALL use#run() @L82 -> Container#get(symbol) +boundary_lib METHOD_CALL use#viaRegistry(Registry) @L87 -> Registry#lookup(string) +known_edge CONSTRUCTOR_CALL use#(?) @L30 -> Sq#(number) +known_edge CONSTRUCTOR_CALL use#(?) @L31 -> Circ#(number) +known_edge CONSTRUCTOR_CALL use#(?) @L38 -> Sq#(number) +known_edge CONSTRUCTOR_CALL use#() @L44 -> Sq#(number) +known_edge FUNCTION_CALL use#ctlGenericUnbound(() =) @L66 -> use#() +known_edge FUNCTION_CALL use#recvGeneric(() =) @L25 -> use#() +known_edge FUNCTION_CALL use#recvGenericArg((x: T) =,User) @L51 -> use#(T) +known_edge METHOD_CALL use#ctlCast() @L61 -> User#save() +known_edge METHOD_CALL use#ctlDeclared() @L57 -> User#save() +known_edge METHOD_CALL use#recordArea(string,number) @L40 -> use#(number) +known_edge METHOD_CALL use#recvGeneric(() =) @L25 -> User#touch() +known_edge METHOD_CALL use#recvGenericArg((x: T) =,User) @L51 -> User#save() +known_edge METHOD_CALL use#totalArea(string,number) @L34 -> use#(number) +known_edge METHOD_CALL use#viaRegistry(Registry) @L87 -> User#touch() +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Circ#area() +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Shape#area() +multi_inferred METHOD_CALL use#indexValue(string) @L46 -> Sq#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Circ#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Shape#area() +multi_inferred METHOD_CALL use#recordArea(string,number) @L40 -> Sq#area() +multi_inferred METHOD_CALL use#run() @L83 -> Ninja#fight() +multi_inferred METHOD_CALL use#run() @L83 -> Warrior#fight() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Circ#area() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Shape#area() +multi_inferred METHOD_CALL use#totalArea(string,number) @L34 -> Sq#area() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.lib.envelope b/graph/test/typescript/expected/80-generic-return-receiver.lib.envelope new file mode 100644 index 000000000..aee022b92 --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.lib.envelope @@ -0,0 +1,6 @@ +nominal use#Shape.area -> use#Circ.area +nominal use#Shape.area -> use#Sq.area +nominal use#Warrior.fight -> use#Ninja.fight +value_call use#recordArea -> use#@38 +value_call use#totalArea -> use#@30 +value_call use#totalArea -> use#@31 diff --git a/graph/test/typescript/expected/80-generic-return-receiver.lib.known-missing b/graph/test/typescript/expected/80-generic-return-receiver.lib.known-missing new file mode 100644 index 000000000..53d6b5be2 --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.lib.known-missing @@ -0,0 +1,6 @@ +# Accepted gaps for 80-generic-return-receiver — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# `Map` is the standard library's, which no case pass loads; it resolves on a real index +# given the TypeScript lib as a library. +use#recvMapGet() -> User#touch() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.lib.oracle b/graph/test/typescript/expected/80-generic-return-receiver.lib.oracle new file mode 100644 index 000000000..cab2609dc --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.lib.oracle @@ -0,0 +1,9 @@ +oracle=23 engine=29 agree=22 missing=1 (known 1, NEW 0) extra=7 + known use#recvMapGet() -> User#touch() + extra use#indexValue(string) -> Circ#area() + extra use#indexValue(string) -> Sq#area() + extra use#recordArea(string,number) -> Circ#area() + extra use#recordArea(string,number) -> Sq#area() + extra use#run() -> Ninja#fight() + extra use#totalArea(string,number) -> Circ#area() + extra use#totalArea(string,number) -> Sq#area() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.oracle b/graph/test/typescript/expected/80-generic-return-receiver.oracle new file mode 100644 index 000000000..431cee181 --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.oracle @@ -0,0 +1,10 @@ +oracle=19 engine=22 agree=16 missing=3 (known 3, NEW 0) extra=6 + known use#recvMapGet() -> User#touch() + known use#run() -> Warrior#fight() + known use#viaRegistry(Registry) -> User#touch() + extra use#indexValue(string) -> Circ#area() + extra use#indexValue(string) -> Sq#area() + extra use#recordArea(string,number) -> Circ#area() + extra use#recordArea(string,number) -> Sq#area() + extra use#totalArea(string,number) -> Circ#area() + extra use#totalArea(string,number) -> Sq#area() diff --git a/graph/test/typescript/expected/80-generic-return-receiver.type-use b/graph/test/typescript/expected/80-generic-return-receiver.type-use new file mode 100644 index 000000000..2c639014f --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.type-use @@ -0,0 +1,16 @@ +ambiguous_unknown METHOD_PARAM 0 use [METHOD_PARAM] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 use [EXPRESSION] -> - +ambiguous_unknown VARIABLE_TYPE 0 use [VARIABLE] -> - +known_edge AS_TARGET 0 use [EXPRESSION] -> User +known_edge IMPLEMENTS_INTERFACE 0 Circ [HERITAGE] -> Shape +known_edge IMPLEMENTS_INTERFACE 0 Ninja [HERITAGE] -> Warrior +known_edge IMPLEMENTS_INTERFACE 0 Sq [HERITAGE] -> Shape +known_edge INDEX_SIGNATURE_VALUE 1 use [VARIABLE] -> Shape +known_edge METHOD_PARAM 0 use [METHOD_PARAM] -> User +known_edge METHOD_RETURN 2 use [VARIABLE] -> Shape +known_edge METHOD_TYPE_ARGUMENT 0 use [EXPRESSION] -> User +known_edge METHOD_TYPE_ARGUMENT 0 use [EXPRESSION] -> Warrior +known_edge OBJECT_CREATION_TYPE 0 use [EXPRESSION] -> Circ +known_edge OBJECT_CREATION_TYPE 0 use [EXPRESSION] -> Sq +known_edge TYPE_ARGUMENT 1 use [METHOD_PARAM] -> User +known_edge VARIABLE_TYPE 0 use [VARIABLE] -> User diff --git a/graph/test/typescript/expected/80-generic-return-receiver.types-oracle b/graph/test/typescript/expected/80-generic-return-receiver.types-oracle new file mode 100644 index 000000000..5b8259a0b --- /dev/null +++ b/graph/test/typescript/expected/80-generic-return-receiver.types-oracle @@ -0,0 +1,7 @@ +80-generic-return-receiver [types] + precision 1.0000 (8 correct, 0 wrong) + recall 1.0000 (8 of 8 the compiler resolved) + sites 22 resolved 18 (81.8%) + tiers ambiguous_unknown=4 known_edge=18 + contexts AS_TARGET=1 IMPLEMENTS_INTERFACE=3 INDEX_SIGNATURE_VALUE=1 METHOD_PARAM=2 METHOD_RETURN=2 METHOD_TYPE_ARGUMENT=4 OBJECT_CREATION_TYPE=6 TYPE_ARGUMENT=1 VARIABLE_TYPE=2 + not scored: 4 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.edges b/graph/test/typescript/expected/80-method-passed-by-reference.edges new file mode 100644 index 000000000..3d1be41b8 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.edges @@ -0,0 +1,20 @@ +ambient_terminal FUNCTION_CALL Widget#now(number[]) @L40 -> globals#setTimeout(() =,number) +ambient_terminal FUNCTION_CALL Widget#wire(Target) @L24 -> globals#setTimeout(() =,number) +ambiguous_unknown METHOD_CALL Widget#now(number[]) @L40 -> - +ambiguous_unknown METHOD_CALL Widget#start(number[]) @L18 -> - +ambiguous_unknown METHOD_CALL Widget#wire(Target) @L24 -> - +callback_registered FUNCTION_CALL Widget#wire(Target) @L24 -> Widget#onHover() +callback_registered METHOD_CALL Widget#relay(Widget,number[]) @L29 -> Widget#handle(number) +callback_registered METHOD_CALL Widget#start(number[]) @L18 -> Widget#handle(number) +callback_registered METHOD_CALL Widget#start(number[]) @L19 -> Widget#handle(number) +callback_registered METHOD_CALL Widget#tap(Target) @L34 -> Widget#() +callback_registered METHOD_CALL Widget#wire(Target) @L23 -> Widget#onClick() +known_edge METHOD_CALL Widget#() @L34 -> Widget#onTap() +known_edge METHOD_CALL Widget#now(number[]) @L41 -> Array#forEach((v: T) =,unknown) +known_edge METHOD_CALL Widget#now(number[]) @L42 -> Array#forEach((v: T) =,unknown) +known_edge METHOD_CALL Widget#relay(Widget,number[]) @L29 -> Array#forEach((v: T) =,unknown) +known_edge METHOD_CALL Widget#start(number[]) @L18 -> Array#forEach((v: T) =,unknown) +known_edge METHOD_CALL Widget#start(number[]) @L19 -> Array#map((v: T) =,unknown) +known_edge METHOD_CALL Widget#tap(Target) @L34 -> Target#addEventListener(string,() =) +known_edge METHOD_CALL Widget#wire(Target) @L23 -> Target#addEventListener(string,() =) +known_edge PROPERTY_READ Widget#now(number[]) @L42 -> Widget#current() diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.entries b/graph/test/typescript/expected/80-method-passed-by-reference.entries new file mode 100644 index 000000000..306627ee7 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + exported_from_entry_module globals#setTimeout globals.d.ts:19 + unimported_module globals# globals.d.ts:1 + unimported_module widget# widget.ts:1 diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.envelope b/graph/test/typescript/expected/80-method-passed-by-reference.envelope new file mode 100644 index 000000000..8432d19e7 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.envelope @@ -0,0 +1,2 @@ +value widget#@14:12 -> widget#Widget.@14 +value widget#@45:18 -> widget#Widget.@45 diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.fields b/graph/test/typescript/expected/80-method-passed-by-reference.fields new file mode 100644 index 000000000..1f90c5744 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.fields @@ -0,0 +1,2 @@ +known_edge read Widget#handle(number) -> Widget#scale +known_edge read Widget#now(number[]) -> Widget#label diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.fields-oracle b/graph/test/typescript/expected/80-method-passed-by-reference.fields-oracle new file mode 100644 index 000000000..0cf12b3fc --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.fields-oracle @@ -0,0 +1,7 @@ +80-method-passed-by-reference [fields] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.oracle b/graph/test/typescript/expected/80-method-passed-by-reference.oracle new file mode 100644 index 000000000..196060769 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.oracle @@ -0,0 +1,3 @@ +oracle=8 engine=10 agree=8 missing=0 (known 0, NEW 0) extra=2 + extra Widget#now(number[]) -> globals#setTimeout(() =,number) + extra Widget#wire(Target) -> globals#setTimeout(() =,number) diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.type-use b/graph/test/typescript/expected/80-method-passed-by-reference.type-use new file mode 100644 index 000000000..624c1ffb8 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.type-use @@ -0,0 +1,4 @@ +known_edge METHOD_PARAM 0 Widget [METHOD_PARAM] -> Target +known_edge METHOD_PARAM 0 Widget [METHOD_PARAM] -> Widget +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function diff --git a/graph/test/typescript/expected/80-method-passed-by-reference.types-oracle b/graph/test/typescript/expected/80-method-passed-by-reference.types-oracle new file mode 100644 index 000000000..a08ef86e2 --- /dev/null +++ b/graph/test/typescript/expected/80-method-passed-by-reference.types-oracle @@ -0,0 +1,7 @@ +80-method-passed-by-reference [types] + precision 1.0000 (4 correct, 0 wrong) + recall 1.0000 (4 of 4 the compiler resolved) + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + contexts METHOD_PARAM=3 SUPER_TYPE=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.edges b/graph/test/typescript/expected/80-object-literal-member-receivers.edges new file mode 100644 index 000000000..768bd67cb --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.edges @@ -0,0 +1,19 @@ +ambiguous_unknown METHOD_CALL api#(string) @L31 -> - +ambiguous_unknown METHOD_CALL local#trimName() @L12 -> - +known_edge FUNCTION_CALL api#(string) @L8 -> api#getUserById(string) +known_edge FUNCTION_CALL api#list() @L7 -> api#listUsers() +known_edge FUNCTION_CALL local#() @L5 -> local#helper() +known_edge FUNCTION_CALL use#callGreeter() @L16 -> api#makeGreeter() +known_edge FUNCTION_CALL use#callStore() @L11 -> api#makeStore(number) +known_edge FUNCTION_CALL use#callStore() @L9 -> api#makeStore(number) +known_edge METHOD_CALL local#drive() @L7 -> local#() +known_edge METHOD_CALL local#drive() @L7 -> local#stop() +known_edge METHOD_CALL use#callGreeter() @L16 -> Greeter#greet(string) +known_edge METHOD_CALL use#callNested() @L4 -> api#(string) +known_edge METHOD_CALL use#callNested() @L4 -> api#(string) +known_edge METHOD_CALL use#callNested() @L4 -> api#list() +known_edge METHOD_CALL use#callStore() @L10 -> api#inc() +known_edge METHOD_CALL use#callStore() @L11 -> api#() +known_edge METHOD_CALL use#callTop() @L5 -> api#(string) +known_edge METHOD_CALL use#callTop() @L5 -> api#getUserById(string) +known_edge METHOD_CALL use#callTop() @L5 -> api#ping() diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.entries b/graph/test/typescript/expected/80-object-literal-member-receivers.entries new file mode 100644 index 000000000..0edeb73d4 --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module local#drive local.ts:7 + exported_from_entry_module local#trimName local.ts:12 + exported_from_entry_module use#callGreeter use.ts:16 + exported_from_entry_module use#callNested use.ts:4 + exported_from_entry_module use#callStore use.ts:8 + exported_from_entry_module use#callTop use.ts:5 + exported_from_entry_module use#keepUnrelated use.ts:20 + unimported_module local# local.ts:1 + unimported_module use# use.ts:1 diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.envelope b/graph/test/typescript/expected/80-object-literal-member-receivers.envelope new file mode 100644 index 000000000..b7ead1fb8 --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.envelope @@ -0,0 +1,2 @@ +value api#Greeter.greet -> api#@31 +value api#Greeter.greet -> api#greet diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields b/graph/test/typescript/expected/80-object-literal-member-receivers.fields new file mode 100644 index 000000000..4b5310455 --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields @@ -0,0 +1,3 @@ +ambiguous_unknown read local#drive() -> - +ambiguous_unknown read local#trimName() -> - +ambiguous_unknown read use#callNested() -> - diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle new file mode 100644 index 000000000..66475c284 --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle @@ -0,0 +1,16 @@ +80-object-literal-member-receivers [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 9 the compiler resolved) + sites 6 resolved 0 (0.0%) + tiers ambiguous_unknown=6 + access read=6 + not scored: 6 rows whose target is not a client declaration + MISSING local#drive() READ local#jobs + MISSING local#drive() READ local#run + MISSING local#trimName() READ local#name + MISSING use#callNested() READ api#byName + MISSING use#callNested() READ api#get + MISSING use#callNested() READ api#users + MISSING use#callStore() READ api#reset + MISSING use#callTop() READ api#find + MISSING use#callTop() READ api#remove diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.oracle b/graph/test/typescript/expected/80-object-literal-member-receivers.oracle new file mode 100644 index 000000000..e6f0ab28d --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.oracle @@ -0,0 +1 @@ +oracle=16 engine=16 agree=16 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.type-use b/graph/test/typescript/expected/80-object-literal-member-receivers.type-use new file mode 100644 index 000000000..900135eaf --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.type-use @@ -0,0 +1,2 @@ +known_edge METHOD_RETURN 0 api [METHOD] -> Greeter +known_edge VARIABLE_TYPE 0 api [VARIABLE] -> Greeter diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.types-oracle b/graph/test/typescript/expected/80-object-literal-member-receivers.types-oracle new file mode 100644 index 000000000..c239494d5 --- /dev/null +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.types-oracle @@ -0,0 +1,7 @@ +80-object-literal-member-receivers [types] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + contexts METHOD_RETURN=1 VARIABLE_TYPE=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-oracle.mjs b/graph/test/typescript/ground-truth/tsc-oracle.mjs index a30200c0b..68fd01f35 100644 --- a/graph/test/typescript/ground-truth/tsc-oracle.mjs +++ b/graph/test/typescript/ground-truth/tsc-oracle.mjs @@ -356,7 +356,12 @@ for (const sf of program.getSourceFiles()) { // So the loss was a convention disagreement, not a missing site, and it is fixed // on this side because the parser's choice is the more useful one: the identifier // is what a reader looks for, and `@` is not part of the callee. - const posNode = ts.isDecorator(node) ? node.expression : node; + // `@(guarded)` is positioned at `guarded`, as the parser does: the parentheses + // are punctuation, not the callee. + let posNode = ts.isDecorator(node) ? node.expression : node; + while (ts.isDecorator(node) && ts.isParenthesizedExpression(posNode)) { + posNode = posNode.expression; + } const [line, col] = pos(sf, posNode.getStart(sf)); // The END position is part of the key, not decoration. A chained call // `a.b().c()` and its inner `a.b()` START at the same character, so diff --git a/graph/test/typescript/tools/entry_report.py b/graph/test/typescript/tools/entry_report.py index a9308c2e7..fb0644d6d 100755 --- a/graph/test/typescript/tools/entry_report.py +++ b/graph/test/typescript/tools/entry_report.py @@ -96,6 +96,22 @@ def main(): print(f"── di_edge ({len(di)}) ──") for status, slot, bean in sorted(di): print(f" {status:11s} {slot} <- {bean}") + # The cross-process edge (framework-behavior/destinations.dl) is not a call either, + # so .edges cannot see it. Printed only when present: every other case's golden + # stays as it was. + for rel, cols in (('remote-edge', 5), ('remote-unserved', 3), ('remote-unsent', 3), + ('remote-undetermined', 3)): + p = os.path.join(out, rel + '.csv') + got = set() + if os.path.exists(p): + with open(p, newline='') as fh: + for r in csv.reader(fh, delimiter='\t'): + if len(r) >= cols: + got.add(tuple(name.get(x, x).split(' ')[0] for x in r[:cols])) + if got: + print(f"── {rel.replace('-', '_')} ({len(got)}) ──") + for row in sorted(got): + print(' ' + ' '.join(row)) return 0 diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 944fa0171..3e093b7a6 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -131,3 +131,74 @@ ts_framework_lifecycle_method("onModuleInit"). ts_framework_lifecycle_me ts_framework_lifecycle_method("onApplicationBootstrap"). ts_framework_lifecycle_method("onApplicationShutdown"). ts_framework_lifecycle_method("beforeApplicationShutdown"). + +// ============================================================================ +// CROSS-PROCESS DESTINATIONS (framework-behavior/destinations.dl) +// +// The names the remote_edge rules key on. As in Java and C#, no rule there names a +// framework: adding an HTTP client or a route decorator is a fact added here. +// ============================================================================ + +// ── the vocabulary of the edge itself ─────────────────────────────────────── +.decl ts_remote_transport_http(c0:symbol) +ts_remote_transport_http("http"). +.decl ts_remote_confidence_exact(c0:symbol) +ts_remote_confidence_exact("exact"). +// a placeholder on one end stood in for a literal on the other +.decl ts_remote_confidence_route_shape(c0:symbol) +ts_remote_confidence_route_shape("route_shape"). +// the verb that matches every other one: `app.all`, `@All`, a request whose method +// is computed +.decl ts_http_verb_any(c0:symbol) +ts_http_verb_any("ANY"). + +// ── ts_http_verb_name(Written, Verb) — a verb as a program spells it ───────── +// A router method is lower case, a `method:` option is either. `all` is any verb. +// `use` and `route` are deliberately absent: `use` mounts middleware or a router +// under a PREFIX, which is not a route a client can name. +.decl ts_http_verb_name(c0:symbol, c1:symbol) +ts_http_verb_name("get", "GET"). ts_http_verb_name("GET", "GET"). +ts_http_verb_name("post", "POST"). ts_http_verb_name("POST", "POST"). +ts_http_verb_name("put", "PUT"). ts_http_verb_name("PUT", "PUT"). +ts_http_verb_name("delete", "DELETE"). ts_http_verb_name("DELETE", "DELETE"). +ts_http_verb_name("patch", "PATCH"). ts_http_verb_name("PATCH", "PATCH"). +ts_http_verb_name("head", "HEAD"). ts_http_verb_name("HEAD", "HEAD"). +ts_http_verb_name("options", "OPTIONS"). ts_http_verb_name("OPTIONS", "OPTIONS"). +ts_http_verb_name("all", "ANY"). + +// ── ts_route_decorator_verb(Decorator, Verb) — a Nest method route ────────── +.decl ts_route_decorator_verb(c0:symbol, c1:symbol) +ts_route_decorator_verb("Get", "GET"). ts_route_decorator_verb("Post", "POST"). +ts_route_decorator_verb("Put", "PUT"). ts_route_decorator_verb("Delete", "DELETE"). +ts_route_decorator_verb("Patch", "PATCH"). ts_route_decorator_verb("Head", "HEAD"). +ts_route_decorator_verb("Options", "OPTIONS"). ts_route_decorator_verb("All", "ANY"). + +// ── ts_route_prefix_decorator(Name) — a class decorator whose argument prefixes +// every route of its methods (`@Controller('orders')`) +.decl ts_route_prefix_decorator(c0:symbol) +ts_route_prefix_decorator("Controller"). + +// ── the client end ────────────────────────────────────────────────────────── +// ts_http_fetch_function(Name): a function that sends its first argument as a URL, +// with the verb in the `method` of its second. The platform's `fetch` is an ambient +// global; the packages that polyfill it export the same function. +.decl ts_http_fetch_function(c0:symbol) +ts_http_fetch_function("fetch"). +.decl ts_http_fetch_module(c0:symbol) +ts_http_fetch_module("node-fetch"). ts_http_fetch_module("cross-fetch"). +ts_http_fetch_module("undici"). ts_http_fetch_module("isomorphic-fetch"). +// ts_http_init_method_key(Key): the option of a request init that names the verb +.decl ts_http_init_method_key(c0:symbol) +ts_http_init_method_key("method"). + +// ts_http_client_module(Specifier): a package whose default or named export is a +// client called as `.(url, …)`. Matched on the IMPORT, never on the +// receiver's spelling: a variable that happens to be called `axios` is not one. +.decl ts_http_client_module(c0:symbol) +ts_http_client_module("axios"). +// ts_http_client_factory(Name): `axios.create({ baseURL })` returns a client whose +// every URL is relative to the base +.decl ts_http_client_factory(c0:symbol) +ts_http_client_factory("create"). +.decl ts_http_base_url_key(c0:symbol) +ts_http_base_url_key("baseURL"). diff --git a/graph/typescript/engine/expression-resolution/callee-resolution.dl b/graph/typescript/engine/expression-resolution/callee-resolution.dl index 5d6506f0f..b1de05bf7 100644 --- a/graph/typescript/engine/expression-resolution/callee-resolution.dl +++ b/graph/typescript/engine/expression-resolution/callee-resolution.dl @@ -39,6 +39,18 @@ call_callee_expr(cs, ce, ne) :- call_site("client", _, _, _, _, ce, cs), expr_child("client", ce, "METHOD_NAME", _, ne). +// ── A BARE DECORATOR IS ITS OWN CALLEE ────────────────────────────────────── +// `@guarded` has no CALL_EXPRESSION around it: the runtime invokes the named function +// with (target, key, descriptor), so the parser's call site points at the NAME itself, +// and that name is also the callee position. `@timed()` keeps its ordinary shape — the +// call is the site and `timed` its METHOD_NAME child. On a codebase that decorates most +// public methods this was most of the compiler's sites missing from the graph (#233). +bare_decorator_call(ce) :- call_site("client", "DECORATOR_CALL", _, _, _, ce, _), + expr_kind("client", k, _, ce), + k != "CALL_EXPRESSION". +call_callee_expr(cs, ce, ce) :- call_site("client", "DECORATOR_CALL", _, _, _, ce, cs), + bare_decorator_call(ce). + // A receiver type must be searchable, so every type a receiver actually takes is // seeded into the member-lookup scope. Bounded to types used as receivers, exactly // as Java bounds `lookup_type`; without it a static-qualifier receiver reached only @@ -211,6 +223,73 @@ expr_call_candidate(ce, m) :- call_site("client", _, cn, rk, recv, ce, _), expr_referenced("client", "VARIABLE", v, recv), objlit_prop_arrow(v, cn, m). +// (5e) THE LITERAL A RECEIVER EVALUATES TO, HOWEVER IT GOT THERE — `api.users.list()`, +// `userApi.fetch(id)` with `userApi` imported, `makeStore().inc()`. (5b)/(5c) join a bare +// VARIABLE reference to its own initializer, so an API module failed on every other road +// to the same literal: an import of the const, a property of it that is itself a literal, +// and a factory whose unannotated return type IS the literal it returns. The compiler +// names the literal's member on each road, because an unannotated literal's type is its +// own shape. +// +// UNANNOTATED ONLY, on both the variable and the function: an annotation is the type the +// compiler answers with (`const g: Greeter = {…}` resolves `g.greet` to Greeter.greet), +// and (4)/(6) already reach it; the literal then implements it (value-flow.dl). +expr_objlit(e, l) :- expr_referenced("client", "VARIABLE", v, e), var_objlit(v, l). +expr_objlit(e, l) :- expr_referenced("client", "IMPORT_BINDING", ih, e), + import_binds(ih, _, "VARIABLE", v), + var_objlit(v, l). +// `api.users`: a property of a literal whose value is a literal +expr_objlit(e, l) :- property_access_name(e, n), + expr_child("client", e, "RECEIVER", _, q), + expr_objlit(q, l0), + objlit_prop(l0, n, val), + literal_value_objlit(val, l). +// `makeStore(1)`: the call's value is what its callee returns +expr_objlit(ce, l) :- expr_kind("client", "CALL_EXPRESSION", _, ce), + expr_call_candidate(ce, f), + method_returns_objlit(f, l). + +var_objlit(v, l) :- var_initializer("client", _, e, v), + !var_type_ref(_, _, v), + literal_value_objlit(e, l). +var_objlit(v, l) :- var_initializer("client", _, e, v), + !var_type_ref(_, _, v), + expr_kind("client", "CALL_EXPRESSION", _, e), + expr_objlit(e, l). + +method_returns_objlit(f, l) :- method_returns_expr(f, e), + expr_kind("client", _, "ROOT", e), + !method_return_ref(_, _, f), + literal_value_objlit(e, l). +// `() => ({ … })`: a concise body is not a return statement +method_returns_objlit(f, l) :- expr_root_context("client", "ARROW_BODY_EXPRESSION", e), + expr_kind("client", _, "ROOT", e), + expr_owner("client", "METHOD", f, _, e), + !method_return_ref(_, _, f), + literal_value_objlit(e, l). + +// a literal, or one written `{ … } satisfies T`, which checks the literal and keeps its type +literal_value_objlit(e, e) :- expr_kind("client", "OBJECT_LITERAL", _, e). +literal_value_objlit(e, l) :- expr_kind("client", "SATISFIES_EXPRESSION", _, e), + expr_child("client", e, "SATISFIES_OPERAND", _, x), + literal_value_objlit(x, l). + +// the literal's method or function-valued property (`get: (id) => …`, `byName: function …`) +expr_call_candidate(ce, m) :- call_site("client", _, cn, rk, recv, ce, _), + receiver_kind_is_value(rk), + cn != "", + expr_objlit(recv, l), + objlit_member_fn(l, cn, m). +// …or a property naming a function declared elsewhere: `{ find: getUserById }` +expr_call_candidate(ce, m) :- call_site("client", _, cn, rk, recv, ce, _), + receiver_kind_is_value(rk), + cn != "", + expr_objlit(recv, l), + objlit_prop(l, cn, val), + !expr_anon_decl("client", _, val), + !expr_kind("client", "CALL_EXPRESSION", _, val), + expr_callable(val, m). + // (5d) A CLASS FIELD HOLDING AN ARROW — `h.viaField(n)` where the class declares // `readonly viaField = (n: number) => n + 3`. field_is_callable reaches a field whose // DECLARED TYPE carries a call signature; this field has no annotation, so its callable @@ -743,7 +822,7 @@ expr_call_candidate(ce, m) :- call_site("client", ck, cn, "IDENTIFIER", recv, ce cn != "", expr_name("client", nsname, recv), nsname != "", - global_decl(nsname, "TYPE", prov, ns), + global_decl_wide(nsname, "TYPE", prov, ns), type_decl(prov, _, _, _, "NAMESPACE_TYPE", _, ns), namespace_member(prov, ns, cn, "METHOD", m). call_kind_is_member_call("METHOD_CALL"). diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index f7251ef11..7e0a11fc4 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -601,6 +601,20 @@ expr_element_type(e, prov, t) :- expr_type(e, _, qt), expr_element_type(e, prov, t) :- expr_type(e, _, qt), index_signature_in_scope(qt, f), field_type(f, prov, t). +// …and the same signature written INLINE — `const reg: { [k: string]: Handler }` — +// where the qualifier has a SHAPE rather than a named type. And where the signature's +// value is itself a function type, `{ [k: string]: (n: number) => Shape }`, the element +// is that signature: `factories[kind](n).area()` is a call on it, and without the shape +// the call had no candidate and `.area()` no receiver type. +expr_element_type(e, prov, t) :- expr_shape(e, qs), + index_signature_in_scope(qs, f), + field_type(f, prov, t). +expr_element_shape(e, sh) :- expr_type(e, _, qt), + index_signature_in_scope(qt, f), + field_shape(f, sh). +expr_element_shape(e, sh) :- expr_shape(e, qs), + index_signature_in_scope(qs, f), + field_shape(f, sh). // ── `new C()` — the constructed type ─────────────────────────────────────── expr_type(e, prov, t) :- expr_kind("client", "NEW_EXPRESSION", _, e), @@ -614,19 +628,28 @@ expr_exact_type(e, t) :- expr_kind("client", "NEW_EXPRESSION", _, e), // ── a CALL result — `a().b()` ────────────────────────────────────────────── // The chained-receiver case, and the one that closes the recursion. The SOUND // candidate set is used, never the disambiguated one; see the header. +// A bare decorator's site IS its name (callee-resolution.dl), and nothing reads what the +// application returns, so the result is not typed onto the name: that would give `guarded` +// the type of the descriptor it returns, and a callable result would feed back as another +// candidate of the same site. expr_type(e, prov, t) :- expr_call_candidate(e, callee), + !bare_decorator_call(e), method_return_type(callee, prov, t). expr_shape(e, s) :- expr_call_candidate(e, callee), + !bare_decorator_call(e), method_return_shape(callee, s). expr_call_returns_awaited(e, prov, t) :- expr_call_candidate(e, callee), + !bare_decorator_call(e), method_return_awaited(callee, prov, t). expr_element_type(e, prov, t) :- expr_call_candidate(e, callee), + !bare_decorator_call(e), method_return_element(callee, prov, t). // A call whose callee returns `this` — the fluent-builder idiom — keeps the // receiver's type rather than taking the declaring type's. expr_type(e, prov, t) :- ts_call_site(_, _, _, recv, _, e, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _), recv != "", expr_call_candidate(e, callee), + !bare_decorator_call(e), // THE CALLEE'S PROVENANCE IS A VARIABLE, NOT "client". A fluent API declared in a // dependency returns `this` from a LIBRARY declaration, so pinning this to the // client meant the chain stopped dead at the first hop into one: the call itself diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index 486fd5f08..948d5b68e 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -87,6 +87,9 @@ call_expr_argc(ce, n) :- call_site("client", "TAGGED_TEMPLATE_CALL", _, _, _, ce call_has_spread(ce) :- call_site("client", _, _, _, _, ce, cs), call_shape("client", _, si, _, cs), si != "". +// A BARE DECORATOR WRITES NO ARGUMENTS and receives two or three, by decorator system and +// target, so its count is unknown in the same sense a spread's is: arity stands down. +call_has_spread(ce) :- bare_decorator_call(ce). // ── arity_match(CallExpr, Callee) ─────────────────────────────────────────── // Guarded by the candidate set, so the numeric comparison is never a cross product diff --git a/graph/typescript/engine/framework-behavior/destinations.dl b/graph/typescript/engine/framework-behavior/destinations.dl new file mode 100644 index 000000000..7370082c6 --- /dev/null +++ b/graph/typescript/engine/framework-behavior/destinations.dl @@ -0,0 +1,415 @@ +// ============================================================================ +// FRAMEWORK-BEHAVIOR · DESTINATIONS (the cross-process edge) +// +// The TypeScript counterpart of java/, python/ and csharp/engine/framework-behavior/ +// destinations.dl, under the same contract: +// - a rule here models a LIBRARY's run-time behaviour, and every framework name it +// keys on lives in config-resolution/knobs.dl; +// - a rule here may only ADD relations. It never changes call_chain_edge or anything +// the resolution phases own: a framework model is weaker knowledge than a resolved +// call; +// - no compiler can adjudicate it. Its oracle is a running system. +// +// A client and the server that answers it are in different processes. Neither calls +// the other, so every rule in call-edge-generation is silent across that boundary and +// an impact question stops at the send. What ties the two ends together is a ROUTE both +// ends spell. The result is `remote_edge`, deliberately NOT a call_chain_edge: +// +// remote_edge(FromMethod, ToMethod, Transport, Destination, Confidence) +// +// and the halves that could not be joined, reported rather than dropped: +// remote_unserved(Method, Transport, Destination) a send nothing here serves +// remote_unsent(Method, Transport, Destination) a handler nothing here sends to +// remote_undetermined(Method, Transport, Reason) a send whose destination cannot be read +// +// COVERED: HTTP. The server as a router call (`app.get('/x', h)`, `router.post(...)`) +// or a decorated controller (`@Controller('x')` over `@Get(':id')`); the client as +// `fetch` or an HTTP client package called `.(url)`, directly or through +// an instance its factory made with a base URL. A route or a URL is read from a string +// literal, a template, a `+` concatenation, a constant (in this module or imported), +// and a property of a const object literal (a config module). +// +// NOT COVERED YET, stated rather than left to be discovered: +// - a router mounted under a prefix (`app.use('/api', router)`): the routes of the +// router are compared without it; +// - an object-form registration (`fastify.route({ method, url, handler })`); +// - a URL built by a wrapper the client calls with the path (the C# rules bind one +// hop of that; here the wrapper's own send is reported undetermined); +// - messaging (a topic, a queue) and gRPC. +// ============================================================================ + +// remote_edge, remote_unserved, remote_unsent and remote_undetermined are declared in +// souffle/decls_all.dl: they are exported, and the bundle reads an export's arity there. +.decl remote_edge_via(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:symbol) +.decl remote_send_at(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl remote_serves(c0:symbol, c1:symbol, c2:symbol) + +// ───────────────────────────────────────────────────────────────────────────── +// 1. THE STRING AN EXPRESSION HOLDS +// ───────────────────────────────────────────────────────────────────────────── +// Read on demand: only a route's path argument and a send's URL argument, and what +// those are built from, are ever evaluated. Anything computed at run time (a +// parameter, a call's result) becomes the placeholder `{}`, which matches one segment +// of anything on the other end. + +// the specifier an import names (`axios`, `../shared/paths`) +.decl rd_import_path(c0:symbol, c1:symbol) +rd_import_path(h, p) :- ts_import(_, p, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). + +// the const an identifier denotes, in this module or through an import +.decl rd_denotes_var(c0:symbol, c1:symbol) +rd_denotes_var(e, v) :- expr_referenced("client", "VARIABLE", v, e). +rd_denotes_var(e, v) :- expr_referenced("client", "IMPORT_BINDING", ih, e), + import_binds(ih, "client", "VARIABLE", v). + +// a property of an object literal: the key and the value share a position +.decl rd_obj_prop(c0:symbol, c1:symbol, c2:symbol) +rd_obj_prop(o, key, val) :- expr_kind("client", "OBJECT_LITERAL", _, o), + expr_child("client", o, "OBJECT_PROPERTY_KEY", pos, k), expr_literal_value("client", key, k), + expr_child("client", o, "OBJECT_PROPERTY_VALUE", pos, val). + +.decl rd_demand(c0:symbol) +.decl rd_prop_value(c0:symbol, c1:symbol) +rd_demand(c) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e), + expr_child("client", e, "LEFT_OPERAND", _, c). +rd_demand(c) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e), + expr_child("client", e, "RIGHT_OPERAND", _, c). +rd_demand(c) :- rd_demand(e), expr_literal_type("client", "TEMPLATE", e), + expr_child("client", e, "TEMPLATE_SPAN", _, c). +rd_demand(i) :- rd_demand(e), rd_denotes_var(e, v), var_initializer("client", _, i, v), i != "". +rd_demand(val) :- rd_prop_value(_, val). +rd_demand(c) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), + expr_child("client", e, _, _, c). + +// `config.reportsUrl`, where `config` is a const initialised with an object literal +rd_prop_value(e, val) :- rd_demand(e), expr_kind("client", "PROPERTY_ACCESS", _, e), + expr_child("client", e, "RECEIVER", _, r), expr_child("client", e, "PROPERTY_NAME", _, pn), + expr_literal_value("client", key, pn), + rd_denotes_var(r, v), var_initializer("client", _, o, v), rd_obj_prop(o, key, val). + +.decl rd_is_str(c0:symbol) +.decl rd_is_tmpl(c0:symbol) +.decl rd_is_plus(c0:symbol) +.decl rd_is_ref(c0:symbol) +.decl rd_is_prop(c0:symbol) +.decl rd_is_transparent(c0:symbol) +rd_is_str(e) :- rd_demand(e), expr_kind("client", "LITERAL", _, e), expr_literal_type("client", "STRING", e). +rd_is_tmpl(e) :- rd_demand(e), expr_literal_type("client", "TEMPLATE", e). +rd_is_plus(e) :- rd_demand(e), expr_kind("client", "BINARY_EXPRESSION", _, e), expr_operator("client", "+", e). +rd_is_ref(e) :- rd_demand(e), rd_denotes_var(e, v), var_initializer("client", _, i, v), i != "". +rd_is_prop(e) :- rd_prop_value(e, _). +rd_is_transparent(e) :- rd_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), + expr_child("client", e, _, _, _). + +.decl rd_val(c0:symbol, c1:symbol) +// literalValue is the text between the quotes; an empty string has no row at all +rd_val(e, v) :- rd_is_str(e), expr_literal_value("client", v, e). +rd_val(e, "") :- rd_is_str(e), !expr_literal_value("client", _, e). +rd_val(e, cat(l, r)) :- rd_is_plus(e), + expr_child("client", e, "LEFT_OPERAND", _, lc), expr_child("client", e, "RIGHT_OPERAND", _, rc), + rd_val(lc, l), rd_val(rc, r), strlen(l) + strlen(r) < 400. +rd_val(e, v) :- rd_demand(e), rd_denotes_var(e, x), var_initializer("client", _, i, x), i != "", rd_val(i, v). +rd_val(e, v) :- rd_prop_value(e, val), rd_val(val, v). +rd_val(e, v) :- rd_is_transparent(e), expr_child("client", e, _, _, c), rd_val(c, v). +rd_val(e, "{}") :- rd_demand(e), !rd_is_str(e), !rd_is_tmpl(e), !rd_is_plus(e), !rd_is_ref(e), + !rd_is_prop(e), !rd_is_transparent(e). + +// ── a template: its raw text, split at each `${…}` ────────────────────────── +// The parser keeps the template as written, backticks included, and gives each hole +// its own TEMPLATE_SPAN child in order. So hole k of the text is span k, and the value +// is the text between the holes with each span's value put back. +.decl rd_tmpl_raw(c0:symbol, c1:symbol) +rd_tmpl_raw(e, raw) :- rd_is_tmpl(e), expr_literal_value("client", raw, e), strlen(raw) >= 2. +.decl rd_raw(c0:symbol) +rd_raw(raw) :- rd_tmpl_raw(_, raw). +.decl rd_open(c0:symbol, c1:number) +rd_open(raw, i) :- rd_raw(raw), i = range(0, strlen(raw) - 1), substr(raw, i, 2) = "${". +.decl rd_brace(c0:symbol, c1:number) +rd_brace(raw, i) :- rd_raw(raw), i = range(0, strlen(raw)), substr(raw, i, 1) = "}". +.decl rd_hole(c0:symbol, c1:number, c2:number, c3:number) +rd_hole(raw, k, a, b) :- rd_open(raw, a), k = count : { rd_open(raw, j), j < a }, + b = min x : { rd_brace(raw, x), x > a }. +.decl rd_holes(c0:symbol, c1:number) +rd_holes(raw, n) :- rd_raw(raw), n = count : { rd_open(raw, _) }. +// piece k is the text before hole k, and piece n (n holes) the text after the last one +.decl rd_piece(c0:symbol, c1:number, c2:symbol) +rd_piece(raw, 0, t) :- rd_hole(raw, 0, a, _), t = substr(raw, 1, a - 1). +rd_piece(raw, k, t) :- rd_hole(raw, k, a, _), k > 0, rd_hole(raw, k - 1, _, b), t = substr(raw, b + 1, a - b - 1). +rd_piece(raw, n, t) :- rd_holes(raw, n), n > 0, rd_hole(raw, n - 1, _, b), t = substr(raw, b + 1, strlen(raw) - b - 2). +rd_piece(raw, 0, t) :- rd_holes(raw, 0), t = substr(raw, 1, strlen(raw) - 2). +.decl rd_tstep(c0:symbol, c1:number, c2:symbol) +rd_tstep(e, 0, p) :- rd_tmpl_raw(e, raw), rd_piece(raw, 0, p). +rd_tstep(e, k + 1, s) :- rd_tstep(e, k, acc), rd_tmpl_raw(e, raw), rd_holes(raw, n), k < n, + expr_child("client", e, "TEMPLATE_SPAN", pos, c), to_number(pos) = k, rd_val(c, sv), + rd_piece(raw, k + 1, p), strlen(acc) + strlen(sv) < 400, s = cat(acc, cat(sv, p)). +rd_val(e, v) :- rd_tmpl_raw(e, raw), rd_holes(raw, n), rd_tstep(e, n, v). + +// ───────────────────────────────────────────────────────────────────────────── +// 2. THE SERVER: a route and the handler it runs +// ───────────────────────────────────────────────────────────────────────────── +// (method, path as written, verb) +.decl rd_route_raw(c0:symbol, c1:symbol, c2:symbol) + +// ── a router call: `app.get(path, …handlers)` ─────────────────────────────── +// The verb name is never matched alone (config-resolution/knobs.dl says why): the +// first argument must read as a path, and a later one must be a function. The call +// is not a route when its receiver is an HTTP client: `axios.get('/x', config)`. +.decl rd_route_call(c0:symbol, c1:symbol) +rd_route_call(ce, verb) :- call_site("client", "METHOD_CALL", cn, _, r, ce, _), ts_http_verb_name(cn, verb), + !rd_client_root(r). +rd_demand(a) :- rd_route_call(ce, _), expr_child("client", ce, "ARGUMENT", "0", a). +// Express runs every function argument in turn, the route's own handler last +.decl rd_route_fn_arg(c0:symbol, c1:number, c2:symbol) +rd_route_fn_arg(ce, p, m) :- rd_route_call(ce, _), callback_arg(ce, pos, m), p = to_number(pos), p > 0. +rd_route_fn_arg(ce, p, m) :- rd_route_call(ce, _), cfg_named_fn_arg(ce, pos, m), p = to_number(pos), p > 0. +.decl rd_route_last(c0:symbol, c1:number) +rd_route_last(ce, p) :- rd_route_fn_arg(ce, _, _), p = max q : { rd_route_fn_arg(ce, q, _) }. +rd_route_raw(m, p, verb) :- rd_route_call(ce, verb), rd_route_last(ce, k), rd_route_fn_arg(ce, k, m), + expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, p), strlen(p) >= 1, substr(p, 0, 1) = "/". + +// A handler registered under a path read from a constant is as much a root as one +// under a literal: config-resolution/entry-points.dl reads the literal only. +entry_point(m, "http") :- rd_route_call(ce, _), rd_route_last(ce, k), rd_route_fn_arg(ce, k, m), + expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, p), strlen(p) >= 1, substr(p, 0, 1) = "/". + +// ── a decorated controller: `@Controller('orders')` over `@Get(':id')` ───── +// A decorator argument keeps its quotes. +.decl rd_unquote(c0:symbol, c1:symbol) +rd_unquote(raw, v) :- annotation_arg("client", _, raw, "STRING", _, _, _, _), strlen(raw) >= 2, + q = substr(raw, 0, 1), (q = "'" ; q = "\"" ; q = "`"), v = substr(raw, 1, strlen(raw) - 2). +.decl rd_dec_path(c0:symbol, c1:symbol) +rd_dec_path(d, v) :- annotation_arg("client", _, raw, "STRING", "0", _, d, _), rd_unquote(raw, v). +.decl rd_has_dec_path(c0:symbol) +rd_has_dec_path(d) :- rd_dec_path(d, _). +.decl rd_ctl_prefix(c0:symbol, c1:symbol) +rd_ctl_prefix(t, v) :- annotation_on("client", n, _, "CLASS_DECLARATION", t, d), ts_route_prefix_decorator(n), + rd_dec_path(d, v). +rd_ctl_prefix(t, "") :- annotation_on("client", n, _, "CLASS_DECLARATION", t, d), ts_route_prefix_decorator(n), + !rd_has_dec_path(d). +.decl rd_ctl_action(c0:symbol, c1:symbol, c2:symbol) +rd_ctl_action(m, verb, v) :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_route_decorator_verb(n, verb), + rd_dec_path(d, v). +rd_ctl_action(m, verb, "") :- annotation_on("client", n, _, "METHOD_DECLARATION", m, d), ts_route_decorator_verb(n, verb), + !rd_has_dec_path(d). +// joined with a slash each side; the segment split below skips the empty ones +rd_route_raw(m, p, verb) :- rd_ctl_action(m, verb, v), method_owner("client", t, m), rd_ctl_prefix(t, pre), + p = cat("/", cat(pre, cat("/", v))). + +// ───────────────────────────────────────────────────────────────────────────── +// 3. THE CLIENT: a send and the URL it is given +// ───────────────────────────────────────────────────────────────────────────── +// (call expression, sending method, verb, URL as built) +.decl rd_send(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl rd_send_site(c0:symbol, c1:symbol) + +// ── fetch(url, { method }) ────────────────────────────────────────────────── +// The platform's global, or the same function imported from a package that +// provides it. A project's own function called `fetch` is neither. +.decl rd_fetch_call(c0:symbol) +rd_fetch_call(ce) :- call_site("client", "FUNCTION_CALL", cn, _, _, ce, _), ts_http_fetch_function(cn), + expr_child("client", ce, "METHOD_NAME", _, f), expr_referenced("client", "AMBIENT_GLOBAL", _, f). +rd_fetch_call(ce) :- call_site("client", "FUNCTION_CALL", _, _, _, ce, _), + expr_child("client", ce, "METHOD_NAME", _, f), expr_referenced("client", "IMPORT_BINDING", ih, f), + rd_import_path(ih, p), ts_http_fetch_module(p). +// the verb: the init's `method` when it is a literal, GET when there is no init or it +// names no method, and any verb when the method is computed or the init is not +// written in place +.decl rd_fetch_init(c0:symbol, c1:symbol) +rd_fetch_init(ce, o) :- rd_fetch_call(ce), expr_child("client", ce, "ARGUMENT", "1", o). +.decl rd_fetch_method_val(c0:symbol, c1:symbol) +rd_fetch_method_val(ce, val) :- rd_fetch_init(ce, o), rd_obj_prop(o, key, val), ts_http_init_method_key(key). +.decl rd_fetch_verb_lit(c0:symbol, c1:symbol) +rd_fetch_verb_lit(ce, verb) :- rd_fetch_method_val(ce, val), expr_literal_value("client", w, val), ts_http_verb_name(w, verb). +.decl rd_fetch_has_method(c0:symbol) +rd_fetch_has_method(ce) :- rd_fetch_method_val(ce, _). +.decl rd_fetch_has_verb(c0:symbol) +rd_fetch_has_verb(ce) :- rd_fetch_verb_lit(ce, _). +.decl rd_fetch_verb(c0:symbol, c1:symbol) +rd_fetch_verb(ce, v) :- rd_fetch_verb_lit(ce, v). +rd_fetch_verb(ce, v) :- rd_fetch_has_method(ce), !rd_fetch_has_verb(ce), ts_http_verb_any(v). +rd_fetch_verb(ce, "GET") :- rd_fetch_call(ce), !rd_fetch_init(ce, _). +rd_fetch_verb(ce, "GET") :- rd_fetch_init(ce, o), expr_kind("client", "OBJECT_LITERAL", _, o), !rd_fetch_has_method(ce). +rd_fetch_verb(ce, v) :- rd_fetch_init(ce, o), !expr_kind("client", "OBJECT_LITERAL", _, o), ts_http_verb_any(v). +rd_demand(a) :- rd_fetch_call(ce), expr_child("client", ce, "ARGUMENT", "0", a). +rd_send(ce, from, verb, u) :- rd_fetch_verb(ce, verb), expr_child("client", ce, "ARGUMENT", "0", a), rd_val(a, u), + expr_enclosing_method(ce, from). +rd_send_site(ce, from) :- rd_fetch_call(ce), expr_enclosing_method(ce, from). + +// ── .(url, …) ───────────────────────────────────────────────── +// The receiver is the package's import itself, or a const its factory made: +// `const api = axios.create({ baseURL })`, whose every URL is relative to the base. +.decl rd_client_import(c0:symbol) +rd_client_import(r) :- expr_referenced("client", "IMPORT_BINDING", ih, r), rd_import_path(ih, p), ts_http_client_module(p). +.decl rd_factory(c0:symbol) +rd_factory(i) :- call_site("client", "METHOD_CALL", f, _, fr, i, _), ts_http_client_factory(f), rd_client_import(fr). +// (receiver, the factory call that made it, or "" for the import) +.decl rd_client_recv(c0:symbol, c1:symbol) +rd_client_recv(r, "") :- rd_client_import(r). +rd_client_recv(r, i) :- rd_denotes_var(r, v), var_initializer("client", _, i, v), rd_factory(i). +.decl rd_client_root(c0:symbol) +rd_client_root(r) :- rd_client_recv(r, _). +.decl rd_base_val(c0:symbol) +rd_base_val(bv) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, bv), + ts_http_base_url_key(key). +rd_demand(bv) :- rd_base_val(bv). +.decl rd_has_base(c0:symbol) +rd_has_base(i) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, _), + ts_http_base_url_key(key). +.decl rd_base(c0:symbol, c1:symbol) +rd_base("", ""). +rd_base(i, b) :- rd_factory(i), expr_child("client", i, "ARGUMENT", "0", o), rd_obj_prop(o, key, bv), + ts_http_base_url_key(key), rd_val(bv, b). +rd_base(i, "") :- rd_factory(i), !rd_has_base(i). +.decl rd_client_call(c0:symbol, c1:symbol, c2:symbol) +rd_client_call(ce, verb, i) :- call_site("client", "METHOD_CALL", cn, _, r, ce, _), ts_http_verb_name(cn, verb), + rd_client_recv(r, i). +rd_demand(a) :- rd_client_call(ce, _, _), expr_child("client", ce, "ARGUMENT", "0", a). +rd_send(ce, from, verb, u) :- rd_client_call(ce, verb, i), rd_base(i, b), expr_child("client", ce, "ARGUMENT", "0", a), + rd_val(a, x), u = cat(b, x), expr_enclosing_method(ce, from). +rd_send_site(ce, from) :- rd_client_call(ce, _, _), expr_enclosing_method(ce, from). + +// ───────────────────────────────────────────────────────────────────────────── +// 4. ONE SHAPE FOR BOTH ENDS: a path, split into segments +// ───────────────────────────────────────────────────────────────────────────── +// Scheme and host are dropped, a client's query string and fragment are dropped, and +// a segment that is a parameter (`:id`, `{…}`, `*`) or holds a computed part becomes +// the placeholder `{}`. +.decl rd_path_raw(c0:symbol) +rd_path_raw(p) :- rd_route_raw(_, p, _). +rd_path_raw(p) :- rd_send(_, _, _, p). +.decl rd_client_path(c0:symbol) +rd_client_path(p) :- rd_send(_, _, _, p). + +.decl rd_scheme_at(c0:symbol, c1:number) +rd_scheme_at(s, i) :- rd_path_raw(s), strlen(s) >= 3, i = range(0, strlen(s) - 2), substr(s, i, 3) = "://". +.decl rd_scheme_first(c0:symbol, c1:number) +rd_scheme_first(s, i) :- rd_scheme_at(s, _), i = min j : rd_scheme_at(s, j). +.decl rd_after_scheme(c0:symbol, c1:symbol) +rd_after_scheme(s, r) :- rd_scheme_first(s, i), r = substr(s, i + 3, strlen(s) - i - 3). +.decl rd_slash_in(c0:symbol, c1:number) +rd_slash_in(r, i) :- rd_after_scheme(_, r), i = range(0, strlen(r)), substr(r, i, 1) = "/". +.decl rd_no_scheme(c0:symbol, c1:symbol) +rd_no_scheme(s, s) :- rd_path_raw(s), !rd_scheme_at(s, _). +rd_no_scheme(s, p) :- rd_after_scheme(s, r), m = min i : rd_slash_in(r, i), p = substr(r, m, strlen(r) - m). +rd_no_scheme(s, "/") :- rd_after_scheme(s, r), !rd_slash_in(r, _). +// a client's `?` and `#` end the path; a server's `?` marks an optional parameter +.decl rd_cut(c0:symbol, c1:number) +rd_cut(p, i) :- rd_no_scheme(s, p), rd_client_path(s), i = range(0, strlen(p)), (substr(p, i, 1) = "?" ; substr(p, i, 1) = "#"). +.decl rd_norm(c0:symbol, c1:symbol) +rd_norm(s, p) :- rd_no_scheme(s, p), !rd_cut(p, _). +rd_norm(s, q) :- rd_no_scheme(s, p), m = min i : rd_cut(p, i), q = substr(p, 0, m). + +// segments: the text after each slash up to the next one, empty ones skipped +.decl rd_pn(c0:symbol) +rd_pn(n) :- rd_norm(_, n). +.decl rd_pn_slash(c0:symbol, c1:number) +rd_pn_slash(n, -1) :- rd_pn(n), strlen(n) >= 1, substr(n, 0, 1) != "/". +rd_pn_slash(n, i) :- rd_pn(n), i = range(0, strlen(n)), substr(n, i, 1) = "/". +.decl rd_pn_next(c0:symbol, c1:number, c2:number) +rd_pn_next(n, i, j) :- rd_pn_slash(n, i), j = min k : { rd_pn_slash(n, k), k > i }. +.decl rd_pn_has_next(c0:symbol, c1:number) +rd_pn_has_next(n, i) :- rd_pn_next(n, i, _). +.decl rd_pn_seg_at(c0:symbol, c1:number, c2:symbol) +rd_pn_seg_at(n, i, t) :- rd_pn_next(n, i, j), t = substr(n, i + 1, j - i - 1). +rd_pn_seg_at(n, i, t) :- rd_pn_slash(n, i), !rd_pn_has_next(n, i), t = substr(n, i + 1, strlen(n) - i - 1). +.decl rd_pn_nonempty_at(c0:symbol, c1:number) +rd_pn_nonempty_at(n, i) :- rd_pn_seg_at(n, i, t), t != "". +.decl rd_pn_seg_raw(c0:symbol, c1:number, c2:symbol) +rd_pn_seg_raw(n, k, t) :- rd_pn_seg_at(n, i, t), t != "", k = count : { rd_pn_nonempty_at(n, x), x < i }. +.decl rd_seg_ph(c0:symbol) +rd_seg_ph(t) :- rd_pn_seg_raw(_, _, t), substr(t, 0, 1) = ":". +rd_seg_ph(t) :- rd_pn_seg_raw(_, _, t), contains("{", t). +rd_seg_ph("*") :- rd_pn_seg_raw(_, _, "*"). +.decl rd_pn_seg(c0:symbol, c1:number, c2:symbol) +rd_pn_seg(n, k, "{}") :- rd_pn_seg_raw(n, k, t), rd_seg_ph(t). +rd_pn_seg(n, k, t) :- rd_pn_seg_raw(n, k, t), !rd_seg_ph(t). +.decl rd_pn_count(c0:symbol, c1:number) +rd_pn_count(n, c) :- rd_pn(n), c = count : { rd_pn_seg(n, _, _) }. + +// A client's leading placeholder is a BASE ADDRESS (`${API}/orders`) and is dropped +// when a literal segment follows it. A server's is a route parameter and stays. +.decl rd_client_drops_base(c0:symbol) +rd_client_drops_base(s) :- rd_client_path(s), rd_norm(s, n), rd_pn_seg(n, 0, "{}"), rd_pn_count(n, c), c > 1, + rd_pn_seg(n, k, t), k >= 1, t != "{}". +.decl rd_client_shift(c0:symbol, c1:symbol, c2:number) +rd_client_shift(s, n, 1) :- rd_client_path(s), rd_norm(s, n), rd_client_drops_base(s). +rd_client_shift(s, n, 0) :- rd_client_path(s), rd_norm(s, n), !rd_client_drops_base(s). +.decl rd_cseg(c0:symbol, c1:number, c2:symbol) +rd_cseg(s, k - d, t) :- rd_client_shift(s, n, d), rd_pn_seg(n, k, t), k >= d. +.decl rd_ccount(c0:symbol, c1:number) +rd_ccount(s, c - d) :- rd_client_shift(s, n, d), rd_pn_count(n, c). +.decl rd_chas_literal(c0:symbol) +rd_chas_literal(s) :- rd_cseg(s, _, t), t != "{}". +.decl rd_server_path(c0:symbol) +rd_server_path(p) :- rd_route_raw(_, p, _). +.decl rd_sseg(c0:symbol, c1:number, c2:symbol) +rd_sseg(p, k, t) :- rd_server_path(p), rd_norm(p, n), rd_pn_seg(n, k, t). +.decl rd_scount(c0:symbol, c1:number) +rd_scount(p, c) :- rd_server_path(p), rd_norm(p, n), rd_pn_count(n, c). +// `/:id?` in Express: the last parameter may be left off by the caller +.decl rd_server_last_optional(c0:symbol) +rd_server_last_optional(p) :- rd_server_path(p), rd_norm(p, n), rd_pn_count(n, c), rd_pn_seg_raw(n, c - 1, t), + rd_seg_ph(t), strlen(t) >= 2, substr(t, strlen(t) - 1, 1) = "?". + +// the destination a path is reported under: its segments joined back, placeholders as `{}` +.decl rd_dest_step(c0:symbol, c1:number, c2:symbol) +rd_dest_step(s, 0, "") :- rd_client_path(s). +rd_dest_step(s, k + 1, cat(acc, cat("/", t))) :- rd_dest_step(s, k, acc), rd_client_path(s), rd_cseg(s, k, t). +rd_dest_step(p, 0, "") :- rd_server_path(p). +rd_dest_step(p, k + 1, cat(acc, cat("/", t))) :- rd_dest_step(p, k, acc), rd_server_path(p), rd_sseg(p, k, t). +.decl rd_dest(c0:symbol, c1:symbol) +rd_dest(s, d) :- rd_client_path(s), rd_ccount(s, c), c > 0, rd_dest_step(s, c, d). +rd_dest(p, d) :- rd_server_path(p), rd_scount(p, c), c > 0, rd_dest_step(p, c, d). +rd_dest(p, "/") :- rd_server_path(p), rd_scount(p, 0). + +// ── the join ──────────────────────────────────────────────────────────────── +.decl rd_route_pair(c0:symbol, c1:symbol) +rd_route_pair(s, p) :- rd_ccount(s, c), rd_scount(p, c), rd_chas_literal(s). +rd_route_pair(s, p) :- rd_ccount(s, c), rd_scount(p, c + 1), rd_server_last_optional(p), rd_chas_literal(s). +.decl rd_seg_conflict(c0:symbol, c1:symbol) +rd_seg_conflict(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, b), a != b, a != "{}", b != "{}". +.decl rd_seg_literal_eq(c0:symbol, c1:symbol) +rd_seg_literal_eq(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, a), a != "{}". +.decl rd_seg_differs(c0:symbol, c1:symbol) +rd_seg_differs(s, p) :- rd_route_pair(s, p), rd_cseg(s, k, a), rd_sseg(p, k, b), a != b. +.decl rd_route_match(c0:symbol, c1:symbol, c2:symbol) +rd_route_match(s, p, conf) :- rd_route_pair(s, p), !rd_seg_conflict(s, p), rd_seg_literal_eq(s, p), + !rd_seg_differs(s, p), ts_remote_confidence_exact(conf). +rd_route_match(s, p, conf) :- rd_route_pair(s, p), !rd_seg_conflict(s, p), rd_seg_literal_eq(s, p), + rd_seg_differs(s, p), ts_remote_confidence_route_shape(conf). + +.decl rd_verb_ok(c0:symbol, c1:symbol) +rd_verb_ok(a, a) :- rd_send(_, _, a, _). +rd_verb_ok(a, b) :- rd_send(_, _, a, _), rd_route_raw(_, _, b), ts_http_verb_any(a). +rd_verb_ok(a, b) :- rd_send(_, _, a, _), rd_route_raw(_, _, b), ts_http_verb_any(b). + +// ROUTE PRECEDENCE: of the verb-compatible routes a send fits, only those with the +// FEWEST differing segments are kept, so `/orders/{}` does not also land on +// `/orders/export` when `/orders/:id` is there. +.decl rd_seg_diff_n(c0:symbol, c1:symbol, c2:number) +rd_seg_diff_n(s, p, n) :- rd_route_match(s, p, _), n = count : { rd_cseg(s, k, a), rd_sseg(p, k, b), a != b }. +.decl rd_candidate(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:number) +rd_candidate(e, from, to, p, conf, n) :- rd_send(e, from, sv, s), rd_route_match(s, p, conf), + rd_route_raw(to, p, rv), rd_verb_ok(sv, rv), rd_seg_diff_n(s, p, n), from != to. +.decl rd_candidate_best(c0:symbol, c1:number) +rd_candidate_best(e, b) :- rd_candidate(e, _, _, _, _, _), b = min n : rd_candidate(e, _, _, _, _, n). + +remote_edge_via(e, from, to, tr, d, conf) :- ts_remote_transport_http(tr), + rd_candidate(e, from, to, p, conf, n), rd_candidate_best(e, n), rd_dest(p, d). +remote_serves(m, tr, d) :- ts_remote_transport_http(tr), rd_route_raw(m, p, _), rd_dest(p, d). +remote_send_at(e, from, tr, d) :- ts_remote_transport_http(tr), rd_send(e, from, _, s), rd_chas_literal(s), rd_dest(s, d). +.decl rd_send_has_literal(c0:symbol) +rd_send_has_literal(e) :- rd_send(e, _, _, s), rd_chas_literal(s). +// a URL with no literal segment at all, `fetch(url)`: nothing can be said about where +// it goes, and saying so beats silence +remote_undetermined(from, tr, "unresolved_destination") :- ts_remote_transport_http(tr), + rd_send_site(e, from), !rd_send_has_literal(e). + +// ───────────────────────────────────────────────────────────────────────────── +// 5. THE EDGE, AND THE HALVES IT CANNOT JOIN +// ───────────────────────────────────────────────────────────────────────────── +remote_edge(from, to, tr, d, conf) :- remote_edge_via(_, from, to, tr, d, conf). +// judged per SEND SITE: a site that linked is not also reported under another spelling +remote_unserved(m, tr, d) :- remote_send_at(e, m, tr, d), !remote_edge_via(e, _, _, _, _, _). +// a handler nothing here sends to: its client is in another repository +remote_unsent(m, tr, d) :- remote_serves(m, tr, d), !remote_edge(_, m, tr, d, _). diff --git a/graph/typescript/engine/resolution/member-lookup.dl b/graph/typescript/engine/resolution/member-lookup.dl index f0e99b8e4..940ad903f 100644 --- a/graph/typescript/engine/resolution/member-lookup.dl +++ b/graph/typescript/engine/resolution/member-lookup.dl @@ -50,9 +50,11 @@ lookup_scope(s) :- ref_shape_target(_, s). group_of(t, g) :- type_group(_, g, t). // ── scope_sibling(Scope, SiblingScope) — the merged entity ────────────────── -// Every declaration that shares this one's group, including itself. For a shape, -// itself alone. -scope_sibling(t, t2) :- group_of(t, g), group_of(t2, g). +// Every declaration that shares this one's group, including itself — except one from +// another program that cannot see this declaration's file (module-graph.dl, #1574). For +// a shape, itself alone. +scope_sibling(t, t2) :- group_of(t, g), group_of(t2, g), + !merge_sibling_cut(t, t2). scope_sibling(s, s) :- ref_shape_target(_, s). // ============================================================================ @@ -227,7 +229,7 @@ field_is_callable(scope, name, m) :- field_in_scope(scope, name, _, f), // only declared_method / scope_sibling / ancestor_of_merged_group, all of which sit // below the expr_type fixpoint, so the negation stratifies even though lookup_scope // does not. -object_prototype_member(name, m) :- global_decl("Object", "TYPE", _, ot), +object_prototype_member(name, m) :- global_decl_wide("Object", "TYPE", _, ot), scope_sibling(ot, sib), declared_method(sib, name, "false", m). member_declared_anywhere(scope, name, st) :- scope_sibling(scope, sib), @@ -298,12 +300,12 @@ method_in_scope(scope, name, "false", f) :- var_enclosing_type(prov, scope, v), // // Guarded on the name resolving to a real global TYPE, so a project declaring its own // `CallableFunction` does not get the language's members grafted on by name alone. -callable_function_member(name, m) :- global_decl("CallableFunction", "TYPE", _, ft), +callable_function_member(name, m) :- global_decl_wide("CallableFunction", "TYPE", _, ft), scope_sibling(ft, sib), declared_method(sib, name, "false", m). callable_function_declares(name) :- callable_function_member(name, _). -plain_function_member(name, m) :- global_decl("Function", "TYPE", _, ft), +plain_function_member(name, m) :- global_decl_wide("Function", "TYPE", _, ft), scope_sibling(ft, sib), declared_method(sib, name, "false", m). diff --git a/graph/typescript/engine/resolution/module-graph.dl b/graph/typescript/engine/resolution/module-graph.dl index e52e22025..1fb223ece 100644 --- a/graph/typescript/engine/resolution/module-graph.dl +++ b/graph/typescript/engine/resolution/module-graph.dl @@ -781,6 +781,97 @@ global_decl(name, k, prov, h) :- module_decl(prov, _, _, _, "DECLARATION_FILE", !module_is_external(prov, mod), module_local_decl(prov, mod, name, k, h). +// ============================================================================ +// WHICH PROGRAM A CLIENT GLOBAL BELONGS TO (#1574) +// ============================================================================ +// The global scope is per PROGRAM, not per repository. A test fixture that is its own +// program — its own tsconfig, often `noLib` with a hand-written `interface Array { +// map … }` — declares globals that only its own files can see. Treating them as +// repository-wide made every production `xs.map(f)` resolve to the FIXTURE's +// `Array.map`, and from that bodiless signature callback dispatch reached every callback +// passed to `map` anywhere: a TypeScript helper "reached" the Python extractor. +// +// The parser already names each file's governing tsconfig (ts_module c14, one per file, +// program-independent). A client global declared under tsconfig T is visible to a module +// that T governs, or that lies in T's directory tree — the second keeps a root +// `types/global.d.ts` visible to every nested package, which is how monorepos share +// ambient declarations. It is NOT visible to a module outside that tree: a nested +// fixture's globals stop at the fixture, and a sibling package's stop at that package. +// A global with no governing tsconfig, and every LIBRARY global, stays repository-wide: +// there is no program to scope them to, and hiding them would turn answers into misses. +module_tsconfig(m, t) :- ts_module(_, _, _, _, _, _, _, _, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, _, _, _, m, _), + t != "". +client_global_module(h, mod) :- module_is_global("client", mod), + module_local_decl("client", mod, _, _, h). +client_global_module(h, mod) :- module_decl("client", _, _, _, "DECLARATION_FILE", mod), + !module_is_external("client", mod), + module_local_decl("client", mod, _, _, h). +client_global_program(h, t) :- client_global_module(h, mod), module_tsconfig(mod, t). + +// ── ONE MERGED ENTITY PER PROGRAM, NOT PER NAME ───────────────────────────── +// The parser keys a declaration group by NAME, program-independently, so the fixture's +// `interface Array` and production's land in ONE group — and member lookup reads the +// group (scope_sibling), so production's `Array` still offered the fixture's `map` even +// once the NAME stopped resolving to it. Declaration merging happens inside a program. +// A declaration therefore joins another's merged entity only when its own program sees +// that declaration's file: a nested package's `interface Window` augmentation merges into +// the root's `Window` for the package's code (the package's lookup starts from both), but +// the root's `Window` does not pick up the package's members, and a sibling program's +// declaration joins neither. +type_program(t, p) :- type_module("client", m, t), module_tsconfig(m, p). +split_group_program(p) :- group_of(t, g), group_of(t2, g), + type_program(t, p), type_program(t2, p2), p != p2. +merge_sibling_cut(t, t2) :- group_of(t, g), group_of(t2, g), + type_program(t, p), type_program(t2, p2), p != p2, + type_module("client", m, t), + !program_sees(m, p2). + +scoped_program(t) :- client_global_program(_, t). +scoped_program(t) :- split_group_program(t). +path_key(t) :- scoped_program(t). +program_dir(t, d) :- scoped_program(t), path_dir(t, d). + +// ── program_sees(ModuleHash, Tsconfig) ────────────────────────────────────── +// Bounded by (client modules) x (programs that declare a global or a split group), and +// the second is a handful: one row per tsconfig, not per declaration. +program_sees(m, t) :- scoped_program(t), module_tsconfig(m, t). +program_sees(m, t) :- program_dir(t, d), + module_file("client", fp, m), + n = strlen(d), + strlen(fp) >= n, + substr(fp, 0, n) = d. + +// ── global_decl_unscoped(Name, Kind, Prov, Hash) ──────────────────────────── +// The globals that belong to no program: every library global, and a client global +// whose script has no governing tsconfig. +global_decl_unscoped(name, k, prov, h) :- global_decl(name, k, prov, h), + !client_global_program(h, _). + +// ── global_decl_visible(ModuleHash, Name, Kind, Prov, Hash) — per module ──── +// What a name lookup in a CLIENT module reads. Gated on global_name_demanded, exactly as +// the lookup it feeds was, so the width stays (client modules) x (names source writes). +global_decl_visible(m, name, k, prov, h) :- module_decl("client", _, _, _, _, m), + global_name_demanded(name), + global_decl_unscoped(name, k, prov, h). +global_decl_visible(m, name, k, "client", h) :- client_global_program(h, t), + global_decl(name, k, "client", h), + global_name_demanded(name), + program_sees(m, t). + +// ── global_decl_wide(Name, Kind, Prov, Hash) — no module to scope by ──────── +// The readers that ask for a global with no module in hand — Object's prototype +// members, CallableFunction's, `typeof X` on a global variable — take the unscoped +// globals and the globals of a program that sees EVERY client module. A single-program +// repository therefore reads exactly what it did; a nested fixture's `Object` no longer +// lends its `toString` to the whole repository. +client_module_count(n) :- n = count : { module_file("client", _, _) }. +program_module_count(t, n) :- client_global_program(_, t), n = count : { program_sees(_, t) }. +program_is_wide(t) :- program_module_count(t, n), client_module_count(n). +global_decl_wide(name, k, prov, h) :- global_decl_unscoped(name, k, prov, h). +global_decl_wide(name, k, "client", h) :- client_global_program(h, t), + program_is_wide(t), + global_decl(name, k, "client", h). + // ── import_type_module(Specifier, ModuleHash) ─────────────────────────────── // The module named by an inline `import('M')` inside a TYPE. Ambient declarations // claim a specifier outright; a staged package is matched by package root. diff --git a/graph/typescript/engine/resolution/name-resolution.dl b/graph/typescript/engine/resolution/name-resolution.dl index 535226291..afd1a6fc6 100644 --- a/graph/typescript/engine/resolution/name-resolution.dl +++ b/graph/typescript/engine/resolution/name-resolution.dl @@ -111,7 +111,7 @@ global_name_demanded("ReadonlyArray"). name_binds_any(mod, name, prov, k, ent) :- module_decl("client", _, _, _, _, mod), global_name_demanded(name), - global_decl(name, k, prov, ent), + global_decl_visible(mod, name, k, prov, ent), !name_bound_locally(mod, name). // ── name_binds(ModuleHash, Name, Prov, Kind, EntityHash) ──────────────────── @@ -127,7 +127,7 @@ name_binds(mod, name, prov, k, ent) :- module_local_decl(prov, mod, name, k, ent !decl_is_type_only(prov, k, ent). name_binds(mod, name, prov, k, ent) :- module_decl("client", _, _, _, _, mod), global_name_demanded(name), - global_decl(name, k, prov, ent), + global_decl_visible(mod, name, k, prov, ent), !decl_is_type_only(prov, k, ent), !name_bound_locally(mod, name). diff --git a/graph/typescript/engine/resolution/reference-types.dl b/graph/typescript/engine/resolution/reference-types.dl index be12fdfe0..04b5345bb 100644 --- a/graph/typescript/engine/resolution/reference-types.dl +++ b/graph/typescript/engine/resolution/reference-types.dl @@ -120,7 +120,7 @@ typeof_query_member(v, qual, member) :- var_type_ref(_, r, v), // carries the `!callee_expr_bound` negation. These two sit below that fixpoint, which // is the same reason `member_declared_anywhere` is built from them. var_is_function(v, m) :- typeof_query_member(v, qual, member), - global_decl(qual, "VARIABLE", _, gv), + global_decl_wide(qual, "VARIABLE", _, gv), var_type_ref(_, qr, gv), ref_type_target(qr, _, qt), scope_sibling(qt, sib), diff --git a/graph/typescript/engine/resolution/type-resolution.dl b/graph/typescript/engine/resolution/type-resolution.dl index 7f4b94038..2c8e5ef20 100644 --- a/graph/typescript/engine/resolution/type-resolution.dl +++ b/graph/typescript/engine/resolution/type-resolution.dl @@ -615,6 +615,15 @@ ref_element_target(ref, prov, t) :- type_ref(rp, "TYPE_REFERENCE", _, tn, _, _, ref_name_is_container(tn), type_ref_parent(rp, ref, "0", child), ref_type_target(child, prov, t). +// `Record` read by key — `makers[kind](n)` — yields V, which is type argument 1, +// not 0, so it is kept off the container list: iterating a Record does not compile, and +// the list's promise is argument 0. +ref_element_target(ref, prov, t) :- type_ref(rp, "TYPE_REFERENCE", _, "Record", _, _, _, ref), + type_ref_parent(rp, ref, "1", child), + ref_type_target(child, prov, t). +ref_element_shape(ref, sh) :- type_ref(rp, "TYPE_REFERENCE", _, "Record", _, _, _, ref), + type_ref_parent(rp, ref, "1", child), + ref_shape_target(child, sh). ref_name_is_container("Array"). ref_name_is_container("ReadonlyArray"). ref_name_is_container("Iterable"). diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 1989b9460..b8c6aba61 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -232,6 +232,43 @@ handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUM expr_kind("client", "CALL_EXPRESSION", _, x), call_runs(x, f), holder_holds_function(f, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + method_value(x, m). + +// ── 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 +// a static member (`Util.f`), and stops there on purpose: it feeds the callee resolver's own +// negation, so reading the receiver's type from it would put that negation in a cycle. +// Down here every resolver rule has already run, so the member is looked up exactly as an +// instance call `recv.name()` looks it up (callee-resolution rules 4 and 6). Without it a +// method passed by reference had no edge from the function that passes it, where a free +// function passed at the same position had one. +method_value(x, m) :- expr_kind("client", "PROPERTY_ACCESS", _, x), + property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, q), + expr_type(q, _, qt), + method_in_scope(qt, n, "false", m), + !method_is_accessor(m). +method_value(x, m) :- expr_kind("client", "PROPERTY_ACCESS", _, x), + property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, q), + expr_shape(q, s), + method_in_scope(s, n, _, m), + !method_is_accessor(m). +// `String(c.current)` with `get current()` READS the property: the getter runs there and its +// value is what is handed over, and a setter is never read at all. +method_is_accessor(m) :- method_kind(_, "GETTER", _, m). +method_is_accessor(m) :- method_kind(_, "SETTER", _, m). +// `f.bind(thisArg, …)` is `f` with its receiver fixed: what it hands over is `f`. Only `bind`: +// `f.call(…)` and `f.apply(…)` run `f` there and hand over what it returns. +method_value(x, m) :- call_site("client", _, "bind", _, recv, x, _), + recv != "", + method_value(recv, m). +method_value(x, m) :- call_site("client", _, "bind", _, recv, x, _), + recv != "", + expr_callable(recv, m). // ── value_branch(Root, Expr): Expr is a value Root can evaluate to ── value_branch(e, e) :- holder_value(_, e). @@ -259,6 +296,9 @@ holder_holds_function(h, m) :- holder_value(h, e), value_branch(e, x), !expr_kind("client", "CALL_EXPRESSION", _, x), # `return make()` holds what make RETURNS, not make: expr_callable(x, m). # a call node carries its callee as its referenced entity +holder_holds_function(h, m) :- holder_value(h, e), + value_branch(e, x), + method_value(x, m). // …AND WHAT ANOTHER HOLDER HANDS OVER. `run(fn, promise, resolve)` passes the caller's own // parameter on, and a wrapper that forwards a callback is the ordinary shape of one: the // function reaches the inner parameter only through the outer one. diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index d4bd890dd..815055c16 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -23,6 +23,7 @@ .decl body_presence_is_ambient(c0:symbol) .decl body_presence_is_overload(c0:symbol) .decl body_presence_is_signature(c0:symbol) +.decl bare_decorator_call(c0:symbol) .decl call_callee_expr(c0:symbol,c1:symbol,c2:symbol) .decl call_caller(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl call_caller_method(c0:symbol,c1:symbol) @@ -201,6 +202,8 @@ .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl method_value(c0:symbol,c1:symbol) +.decl method_is_accessor(c0:symbol) .decl called_through(c0:symbol,c1:symbol) .decl method_enclosing_member(c0:symbol,c1:symbol) .decl call_runs(c0:symbol,c1:symbol) @@ -217,6 +220,10 @@ .decl field_in_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl field_initializer(c0:symbol,c1:symbol,c2:symbol) .decl objlit_prop_arrow(c0:symbol,c1:symbol,c2:symbol) +.decl expr_objlit(c0:symbol,c1:symbol) +.decl var_objlit(c0:symbol,c1:symbol) +.decl method_returns_objlit(c0:symbol,c1:symbol) +.decl literal_value_objlit(c0:symbol,c1:symbol) .decl field_arrow_callable(c0:symbol,c1:symbol,c2:symbol) .decl field_is_callable(c0:symbol,c1:symbol,c2:symbol) .decl field_is_readonly(c0:symbol) @@ -654,3 +661,22 @@ .decl ref_member_method(c0:symbol,c1:symbol) .decl fn_returns_fn(c0:symbol,c1:symbol) .decl fn_type_return_ref(c0:symbol,c1:symbol) +.decl module_tsconfig(c0:symbol,c1:symbol) +.decl client_global_module(c0:symbol,c1:symbol) +.decl client_global_program(c0:symbol,c1:symbol) +.decl scoped_program(c0:symbol) +.decl type_program(c0:symbol,c1:symbol) +.decl split_group_program(c0:symbol) +.decl merge_sibling_cut(c0:symbol,c1:symbol) +.decl program_dir(c0:symbol,c1:symbol) +.decl program_sees(c0:symbol,c1:symbol) +.decl global_decl_unscoped(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl global_decl_visible(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl client_module_count(c0:number) +.decl program_module_count(c0:symbol,c1:number) +.decl program_is_wide(c0:symbol) +.decl global_decl_wide(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl remote_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl remote_undetermined(c0:symbol,c1:symbol,c2:symbol) +.decl remote_unsent(c0:symbol,c1:symbol,c2:symbol) +.decl remote_unserved(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index fb938b8fa..23b265dfb 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -50,3 +50,7 @@ type_use type-use.csv field_site_excluded field-site-excluded.csv inject_point config-inject-point.csv di_edge config-di-edge.csv +remote_edge remote-edge.csv +remote_unserved remote-unserved.csv +remote_unsent remote-unsent.csv +remote_undetermined remote-undetermined.csv diff --git a/package.json b/package.json index db70f7a16..449d86de7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.6", + "version": "0.1.7", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.6", - "@axiomcode/engine-darwin-x64": "0.1.6", - "@axiomcode/engine-linux-x64": "0.1.6", - "@axiomcode/engine-linux-arm64": "0.1.6", - "@axiomcode/engine-win32-x64": "0.1.6" + "@axiomcode/engine-darwin-arm64": "0.1.7", + "@axiomcode/engine-darwin-x64": "0.1.7", + "@axiomcode/engine-linux-x64": "0.1.7", + "@axiomcode/engine-linux-arm64": "0.1.7", + "@axiomcode/engine-win32-x64": "0.1.7" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts index 092f6f49a..4a816c7ff 100644 --- a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts @@ -263,6 +263,14 @@ export class TsDeclarationExtractor { */ private readonly pendingMemberTypeLinks: { row: TsFieldRegistry; node: ts.TypeNode }[] = []; + /** + * Parameters of a function type whose declared type the enclosing tree emits. + * + * The same shape again: `(x: T) => T` had parameter rows with the type only as + * text, so `pick(u)` through it could not bind T from the argument it was passed. + */ + private readonly pendingParameterTypeLinks: { row: TsMethodParameterRegistry; node: ts.TypeNode }[] = []; + /** * Object-literal members awaiting the hash of the literal that owns them. * @@ -1887,6 +1895,8 @@ export class TsDeclarationExtractor { tsModuleLinkHash: context.moduleHash, }) ); + } else if (parameter.type) { + this.pendingParameterTypeLinks.push({ row, node: parameter.type }); } if (parameter.initializer) { this.pendingExpressionLinks.push({ @@ -2524,6 +2534,12 @@ export class TsDeclarationExtractor { pending.row.setTypeReferenceLinkHash(hash); } } + for (const pending of this.pendingParameterTypeLinks) { + const hash = this.typeReferenceExtractor.hashForTypeNode(pending.node); + if (hash !== '') { + pending.row.setTypeReferenceLinkHash(hash); + } + } for (const pending of this.pendingConstraintLinks) { const hash = this.typeReferenceExtractor.hashForTypeNode(pending.node); if (hash !== '') { diff --git a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts index 330e71177..c5ebcb361 100644 --- a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts @@ -178,7 +178,7 @@ export class TsExpressionExtractor { this.emitted.push({ node: item.node, row }); if (kind === TsExpressionKind.CALL_EXPRESSION || kind === TsExpressionKind.NEW_EXPRESSION || kind === TsExpressionKind.TAGGED_TEMPLATE || kind === TsExpressionKind.JSX_ELEMENT - || kind === TsExpressionKind.JSX_SELF_CLOSING) { + || kind === TsExpressionKind.JSX_SELF_CLOSING || isBareDecorator(item.node)) { this.callNodes.push({ node: item.node, owner: item.owner }); } @@ -856,6 +856,11 @@ function operatorOf(node: ts.Node, sourceFile: ts.SourceFile): string { /** The callee of a call-shaped node, parentheses unwrapped. */ export function calleeOf(node: ts.Node): ts.Node | undefined { + // `@guarded` has no call node around it: the name IS the callee, and the + // runtime supplies the arguments. + if (isBareDecorator(node)) { + return node; + } if (ts.isCallExpression(node) || ts.isNewExpression(node)) { return unwrapParentheses(node.expression); } @@ -953,6 +958,22 @@ function isDecoratorCall(node: ts.Node): boolean { return current !== undefined && ts.isDecorator(current); } +/** + * Whether this node is a decorator applied WITHOUT a call: `@guarded`, + * `@guards.disposed`, `@registry["x"]`. + * + * The runtime invokes the named function with (target, key, descriptor), so the + * application is a call exactly as `@timed()` is, and the compiler resolves it. + * Without a call site of its own it was absent from the IR, which on a codebase + * that decorates most public methods is most of what goes missing (#233). The + * factory form is excluded: its CallExpression is already the call site, and + * the name inside it is that call's callee, not a second call. + */ +export function isBareDecorator(node: ts.Node): boolean { + return (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node)) && isDecoratorCall(node); +} + function callKindOf(node: ts.Node, callee: ts.Node | undefined): TsCallKind { if (ts.isNewExpression(node)) { return TsCallKind.CONSTRUCTOR_CALL; diff --git a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts index 189051d2f..de5be2f9d 100644 --- a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts +++ b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts @@ -840,6 +840,11 @@ function calleeExpressionOf( if (!callRow) { return undefined; } + // A bare `@guarded` is its own callee: the site is the name, with no call + // expression around it (#233). + if (callSite.callKind === 'DECORATOR_CALL' && callRow.kind !== 'CALL_EXPRESSION') { + return callRow; + } return (childrenByParent.get(callRow.getHash()) ?? []) .find((child) => child.edgeRole === 'METHOD_NAME'); } diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts index e7bd514dc..7272f574f 100644 --- a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -25,6 +25,7 @@ import { } from '@/parsers/typescript/extractors/ts-binder'; import { calleeOf, + isBareDecorator, unwrapParentheses, } from '@/parsers/typescript/extractors/ts-expression-extractor'; @@ -1226,6 +1227,13 @@ function callArgumentCount(node: ts.Node): number { if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { return 1; } + // A bare `@guarded` writes no arguments; the runtime passes two or three + // depending on the decorator system and the target, which this pass does not + // know. No count admits any parameterised signature, so a sole candidate is + // still taken and an overload set is left unchosen rather than guessed. + if (isBareDecorator(node)) { + return -1; + } return 0; } diff --git a/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts b/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts index c69103e41..d355a2390 100644 --- a/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts @@ -50,6 +50,8 @@ interface PlannedChild { readonly context: TsTypeRefContext; readonly isOptionalElement?: boolean; readonly isRestElement?: boolean; + /** Type parameters in scope for this child only: a type-literal signature's own ``. */ + readonly introduces?: readonly ts.TypeParameterDeclaration[]; } export class TsTypeReferenceExtractor { @@ -321,6 +323,11 @@ export class TsTypeReferenceExtractor { introduced.push(node.typeParameter); } else if (ts.isConditionalTypeNode(node)) { collectInferParameters(node.extendsType, introduced); + } else if (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node)) { + // `() => T`: the signature's own parameters scope its parameters and its + // return. Without this the return `T` was a TYPE_REFERENCE to nothing, so + // `get()` through a parameter of that type bound T to nothing. + introduced.push(...(node.typeParameters ?? [])); } for (const declared of introduced) { this.typeLevelParameterScope.push(declared); @@ -328,11 +335,21 @@ export class TsTypeReferenceExtractor { try { let index = 0; for (const child of children) { - this.emit(child.node, child.context, owner, row.getHash(), index, depth + 1, - isTypeOnlyPosition, { - isOptionalElement: child.isOptionalElement, - isRestElement: child.isRestElement, - }); + const own = child.introduces ?? []; + for (const declared of own) { + this.typeLevelParameterScope.push(declared); + } + try { + this.emit(child.node, child.context, owner, row.getHash(), index, depth + 1, + isTypeOnlyPosition, { + isOptionalElement: child.isOptionalElement, + isRestElement: child.isRestElement, + }); + } finally { + for (let i = 0; i < own.length; i += 1) { + this.typeLevelParameterScope.pop(); + } + } index += 1; } } finally { @@ -356,7 +373,7 @@ export class TsTypeReferenceExtractor { function plannedChildren(node: ts.TypeNode): PlannedChild[] { const out: PlannedChild[] = []; const push = (child: ts.TypeNode | undefined, context: TsTypeRefContext, - extra?: { isOptionalElement?: boolean; isRestElement?: boolean }): void => { + extra?: Omit): void => { if (child) { out.push({ node: child, context, ...extra }); } @@ -459,6 +476,9 @@ function plannedChildren(node: ts.TypeNode): PlannedChild[] { const memberType = (member as { type?: ts.TypeNode }).type; push(memberType, contextForTypeElement(member), { isOptionalElement: (member as { questionToken?: ts.QuestionToken }).questionToken !== undefined, + // `{ get(): T }`: the member's `T` is in scope for its return, as a function type's is. + introduces: ts.isMethodSignature(member) || ts.isCallSignatureDeclaration(member) + || ts.isConstructSignatureDeclaration(member) ? member.typeParameters : undefined, }); } return out; diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts index 1cb810987..fd295003d 100644 --- a/parser/src/test/typescript-tests.ts +++ b/parser/src/test/typescript-tests.ts @@ -812,6 +812,7 @@ function tscAdjudicatedResolution(): number { const corpora = extractedCorpora(); const failures: string[] = []; let expectedCalls = 0, emittedCalls = 0, projectTargets = 0, agreed = 0, unfilled = 0; + let bareDecorators = 0; const byReceiver = new Map(); for (const c of index.corpora) { @@ -866,8 +867,13 @@ function tscAdjudicatedResolution(): number { } } for (const [where, leftover] of emitted) { - if (leftover.length > 0) { - failures.push(`${c.slug}: parser emits ${leftover.length} extra call site(s) at ${where}`); + // A BARE DECORATOR is a call the compiler resolves (#233), but this oracle + // enumerates call EXPRESSIONS and `@guarded` is only a name, so it has no row + // to match. Counted, not failed: case 32 scores them against the compiler. + const extra = leftover.filter((r) => r.callKind !== 'DECORATOR_CALL'); + bareDecorators += leftover.length - extra.length; + if (extra.length > 0) { + failures.push(`${c.slug}: parser emits ${extra.length} extra call site(s) at ${where}`); } } } @@ -878,7 +884,7 @@ function tscAdjudicatedResolution(): number { .join(' '); console.log(` ${emittedCalls} call site(s) emitted for ${expectedCalls} tsc sees; ` + `${projectTargets} project targets: ${agreed} agree, ${unfilled} left to the engine, ` + - `${failures.length} disagree`); + `${failures.length} disagree; ${bareDecorators} bare decorator(s) this oracle does not enumerate`); // PROVENANCE, not a score. The unfilled column is where the ENGINE resolves, // which is the design; see `IR completeness` for the number that matters. console.log(` provenance — same-file links by receiver shape: ${shapes}`); @@ -1134,11 +1140,17 @@ function factBaseInvariants(): number { } } - // 8. One call site per CALL / NEW / TAGGED_TEMPLATE expression, exactly. - const callShaped = (all.get('all-typescript-expressions.csv') ?? []) + // 8. One call site per CALL / NEW / TAGGED_TEMPLATE expression, exactly, and + // one per bare decorator (`@guarded`), whose site is the name itself (#233). + const expressions = all.get('all-typescript-expressions.csv') ?? []; + const callSiteRows = all.get('all-typescript-call-sites.csv') ?? []; + const kindOfExpression = new Map(expressions.map((r) => [r.tsExpressionUniqueHash ?? '', r.kind])); + const bareDecorators = callSiteRows.filter((r) => r.callKind === 'DECORATOR_CALL' + && kindOfExpression.get(r.tsExpressionLinkHash ?? '') !== 'CALL_EXPRESSION').length; + const callShaped = expressions .filter((r) => r.kind === 'CALL_EXPRESSION' || r.kind === 'NEW_EXPRESSION' - || r.kind === 'TAGGED_TEMPLATE').length; - const callSites = (all.get('all-typescript-call-sites.csv') ?? []).length; + || r.kind === 'TAGGED_TEMPLATE').length + bareDecorators; + const callSites = callSiteRows.length; if (callShaped !== callSites) { failures.push(`${slug}: ${callShaped} call-shaped expression(s) but ${callSites} ` + 'ts_call_site row(s) — the 1:1 chain is broken'); @@ -2807,6 +2819,74 @@ async function decoratorDescendedOnce(): Promise { } } +/** + * A bare decorator is a call site (#233). + * + * `@guarded` is invoked by the runtime exactly as `@timed()` is, and the compiler + * resolves both, but only the factory form had a `ts_call_site`: the bare one was + * absent from the IR. Each bare application is one DECORATOR_CALL positioned at its + * name, where the compiler positions it. THE CONTROLS: the factory form stays ONE + * site (its callee is not a second call), and the same name outside a decorator is + * no call at all. + */ +async function bareDecoratorIsACallSite(): Promise { + if (!parserPresent()) { + return pendingCheck('a bare decorator is a call site', + 'no extractor yet. `@guarded` is a call the runtime makes'); + } + const { outputDir, cleanup } = await analyseInline('ts-baredec-', { + 'a.ts': [ + 'export function guarded(_t: unknown, _k: string, d: PropertyDescriptor) { return d; }', // 1 + 'export function timed(): MethodDecorator { return (_t, _k, d) => d; }', // 2 + 'export class Guards {', // 3 + ' static disposed(_t: unknown, _k: string, d: PropertyDescriptor) { return d; }', // 4 + '}', // 5 + 'export class Svc {', // 6 + ' @guarded a() {}', // 7 + ' @timed() b() {}', // 8 + ' @Guards.disposed c() {}', // 9 + ' @(guarded) d() {}', // 10 + '}', // 11 + 'export const alias = guarded;', // 12 + ].join('\n'), + }, { experimentalDecorators: true }); + try { + const sites = relation(outputDir, 'all-typescript-call-sites.csv'); + const failures: string[] = []; + const expected: [string, string, string][] = [ + ['7', '4', 'guarded'], ['8', '4', 'timed'], ['9', '4', 'disposed'], ['10', '5', 'guarded'], + ]; + for (const [line, column, name] of expected) { + const at = sites.filter((r) => r['startLine'] === line); + if (at.length !== 1) { + failures.push(`${at.length} call site(s) on line ${line}, expected 1`); + continue; + } + const row = at[0]!; + if (row['callKind'] !== 'DECORATOR_CALL' || row['calleeName'] !== name + || row['startColumn'] !== column) { + failures.push(`line ${line}: ${row['callKind']} ${row['calleeName']} @col ` + + `${row['startColumn']}, expected DECORATOR_CALL ${name} @col ${column}`); + } + } + // A local, single-signature decorator is resolved by the parser like any call. + const bare = sites.find((r) => r['startLine'] === '7'); + if (bare && (bare['resolvedSignatureLinkHash'] ?? '') === '') { + failures.push('`@guarded` names a local function and was left unresolved'); + } + const alias = sites.filter((r) => r['startLine'] === '12'); + if (alias.length !== 0) { + failures.push(`${alias.length} call site(s) for \`alias = guarded\`, a reference, not a call`); + } + console.log(` ${sites.length} call site(s); ` + + `${sites.filter((r) => r['callKind'] === 'DECORATOR_CALL').length} DECORATOR_CALL`); + for (const f of failures.slice(0, 6)) { console.log(` ${f}`); } + return failures.length ? 1 : 0; + } finally { + cleanup(); + } +} + /** * `.d.mts` and `.d.cts` are declaration files, and their stem is not `index.d` * (#89). @@ -4661,6 +4741,7 @@ const CHECKS: Check[] = [ { name: 'column order is append-only', proves: 'a column is never inserted mid-table, because Souffle binds by position and misbinds silently', run: columnOrderIsAppendOnly }, { name: 'governing tsconfig is per file', proves: 'a file reachable from two programs mints ONE ts_module row, so no key carries contradictory moduleResolutionMode', run: governingConfigIsPerFile }, { name: 'a decorator is descended once', proves: 'a callable inside a decorator argument is one ts_method, not two colliding on one key as a false overload set', run: decoratorDescendedOnce }, + { name: 'a bare decorator is a call site', proves: '`@guarded` is one DECORATOR_CALL at its name, as `@timed()` is, while the factory stays one site and a plain reference none', run: bareDecoratorIsACallSite }, { name: 'declaration extensions are whole extensions', proves: '`.d.cts` and `.d.mts` are single extensions, so no stem keeps a stray `.d`', run: declarationExtensionsAreWholeExtensions }, { name: 'an annotation can declare a signature set', proves: 'a type literal holding several call signatures is resolved as the set it is, and arity picks the arm tsc picks', run: annotationCanDeclareASignatureSet }, { name: 'an uncheckable annotation leaves the call unresolved', proves: 'a call through a variable whose annotation needs the checker names neither the initialiser nor a single candidate', run: uncheckableAnnotationLeavesCallUnresolved }, diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 28e1ce92e..02e47b328 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.6", + "version": "0.1.7", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index d13a75434..c17386769 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.6", + "version": "0.1.7", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 32feb6586..db0fce09f 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.6", + "version": "0.1.7", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index abff45b86..b1c0821da 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -4,77 +4,57 @@ sessions, 209 were `← 0`, and most of those were methods a framework calls: a route handler, an `@app.before_request`, a scheduled job, a library override. The graph stores why for most of them, so the line says it: `entry (http)`, `0 resolved, 3 by name`, `? framework (@Scheduled)`. A method - with no signal at all still reads `0`. + with no signal at all still reads `0`. The reason is graph_sql.no_caller_reasons, which impact and + path read too. + callers_via_base the callers through an interface or base method, which impact lists and call_edges lacks. body_line an edit that changed only bodies breaks no caller; the one thing worth saying is which tests reach it and how to run them. The block it replaces listed 10-42 readers, and 28 of 30 went unused. Every lookup is one indexed query per declaration (these run on every Read, Grep and Edit).""" import importlib.machinery, importlib.util, os, sqlite3 -# decorations that say nothing about who calls the method: compiler hints, language plumbing, nullness -INERT = {'Override', 'SuppressWarnings', 'Deprecated', 'Serial', 'SafeVarargs', 'FunctionalInterface', 'Nullable', - 'NonNull', 'NotNull', 'Nonnull', 'CheckReturnValue', 'VisibleForTesting', 'Generated', 'staticmethod', - 'classmethod', 'property', 'abstractmethod', 'cached_property', 'functools.wraps', 'wraps', 'override', - 'overload', 'typing.override', 'typing.overload', 'CallerMemberName', 'Obsolete', 'MethodImpl', - 'DebuggerStepThrough', 'Pure', 'Data', 'Getter', 'Setter', 'Value', 'Builder', 'ToString', - 'EqualsAndHashCode', 'NoArgsConstructor', 'AllArgsConstructor', 'RequiredArgsConstructor', 'Slf4j'} +import sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) +import graph_sql -def _has(con, t): - return con.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (t,)).fetchone() is not None +def _q(con): + return lambda sql, *p: con.execute(sql, p).fetchall() -def _deco(con, owner_id): - """the first decoration on a declaration that could mean a framework calls it, as written (`@app.before_request`)""" - for name, text in con.execute("SELECT name, text FROM decorations WHERE owner_id = ? ORDER BY line", (owner_id,)): - if (name or '') in INERT or (name or '').rsplit('.', 1)[-1] in INERT: continue - t = (text or '').split('(')[0].strip() or '@' + (name or '') - if t.startswith('[') and not t.endswith(']'): t += ']' # a C# attribute, `[HttpGet("x")]` - return t[:48] - return None - - -def zero_label(con, mid, name, is_test=0): - """what `← 0` means for method `mid`, from the strongest signal the graph holds: +def zero_label(con, mid, name=None, is_test=0): + """what `← 0` means for method `mid`: the FIRST reason graph_sql.no_caller_reasons gives, the one reader impact's + `next:` and path's empty-upstream note word too, so the three never give different reasons for one method: entry () the runtime invokes it: a route, a test, main, a scheduled job, a listener - ? framework (@X) a decoration on it that is not a compiler hint + ? framework (@X) a decoration a framework reads (a wrapper such as a cache or a permission check is not one) + ? framework (overrides B) it overrides a method the graph does not contain + ? framework (extends B) its type derives from a base outside the graph + ? framework (@X on Owner) a decoration on its type 0 resolved, N by name N call sites write its name on a receiver the engine could not type - ? framework (overrides a library method) @Override with no base in this repository - ? framework (extends B) its class derives from a base outside the graph (Python) - ? framework (@X on Owner) a decoration on its class - 0 none of these: nothing in this graph calls it""" + 0 none of these: nothing in this graph calls it + `name` and `is_test` are read from the graph; the parameters stay for callers that pass them.""" try: - if _has(con, 'entry_points'): - r = con.execute("SELECT reason FROM entry_points WHERE method_id = ? LIMIT 1", (mid,)).fetchone() - if r: return f"entry ({r[0]})" - if is_test: return "entry (test)" - deco = _deco(con, mid) if _has(con, 'decorations') else None - if deco: return f"? framework ({deco})" - if name and _has(con, 'unresolved_sites'): - n = con.execute("""SELECT count(*) FROM call_sites cs JOIN unresolved_sites u ON u.call_site_id = cs.id - WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""", - (name,)).fetchone()[0] - if n: return f"0 resolved, {n} by name" - owner = con.execute("SELECT owner_type_id FROM methods WHERE id = ?", (mid,)).fetchone() if _has(con, 'methods') else None - owner = owner[0] if owner else None - if _has(con, 'decorations') and con.execute( - "SELECT 1 FROM decorations WHERE owner_id = ? AND name = 'Override' LIMIT 1", (mid,)).fetchone() \ - and not con.execute("""SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id - WHERE o.overriding_method_id = ? AND b.provenance = 'client' LIMIT 1""", (mid,)).fetchone(): - return "? framework (overrides a library method)" - if owner and _has(con, 'ext_type_base_unresolved'): - r = con.execute("SELECT c3 FROM ext_type_base_unresolved WHERE c1 = ? LIMIT 1", (owner,)).fetchone() - if r and r[0]: return f"? framework (extends {r[0]})" - if owner and _has(con, 'decorations'): - d = _deco(con, owner) - if d: - on = con.execute("SELECT name FROM types WHERE id = ?", (owner,)).fetchone() if _has(con, 'types') else None - return f"? framework ({d}" + (f" on {on[0]}" if on else '') + ")" + rs = graph_sql.no_caller_reasons(_q(con), [mid]).get(mid) or [] + if not rs and is_test: return "entry (test)" + if rs: return graph_sql.no_caller_label(rs[0][0], rs[0][1]) except sqlite3.Error: pass return "0" +def callers_via_base(con, mids, repo='.'): + """{method id: {caller id}}: the callers through an interface or base method that impact lists and call_edges does + not hold (graph_sql.callers_via_base), read with the repository's lines so a narrowed interface-typed field counts""" + cache = {} + def lines(f): + if f not in cache: + try: cache[f] = open(os.path.join(repo, f), encoding='utf-8', errors='replace').read().splitlines() + except OSError: cache[f] = [] + return cache[f] + try: return graph_sql.callers_via_base(_q(con), list(mids), lines) + except sqlite3.Error: return {} + + def distinct_paths(files): """the shortest path suffix that tells these files apart: two `StubConverterFactory.java` in different modules print as `json/StubConverterFactory.java` and `xml/StubConverterFactory.java`, never as the same line twice""" diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 8d0369649..8972fcc82 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -277,6 +277,18 @@ def visible(f, ln): return f == rel and min(a, lo) <= ln <= max(b - 1, hi) if any(visible(x['f'], x['sl'] or x['ln']) for x in es): continue if e['k'] == 'module': e['ln'] = min((x['sl'] for x in es if x['sl']), default=e['ln']) # a top level is where its call is if e['d'] not in {x['d'] for x in up[m]}: up[m].append(e) # overloads of one caller are one name + # A CALLER THROUGH AN INTERFACE OR A BASE METHOD IS A CALLER (#1542): impact lists it, and call_edges alone does + # not hold it where the engine narrowed an interface-typed field to its one bean. Counted from the same reader. + via = _graphline.callers_via_base(con, mids, cwd) + if via: + vids = sorted({c for cs in via.values() for c in cs}) + vrow = {r['id']: r for r in q(f"SELECT id, display d, file f, line ln, kind k FROM symbols WHERE id IN ({','.join('?' * len(vids))})", *vids)} + for m, cs in via.items(): + anyup.add(m) + known = {e['id'] for (m_, _), es in sites.items() if m_ == m for e in es} # already a call_edges caller + for c in sorted(cs, key=lambda c: (vrow[c]['d'] if c in vrow else c)): + if c in vrow and c not in known and not visible(vrow[c]['f'], vrow[c]['ln']) and vrow[c]['d'] not in {x['d'] for x in up[m]}: + up[m].append(dict(vrow[c], sl=None)) dn = collections.defaultdict(list) for e in q(f"SELECT DISTINCT e.caller_id c, ce.id, ce.display d, ce.file f, ce.line ln FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id IN ({ph}) AND e.callee_provenance = 'client'", *ids): if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in dn[e['c']]}: dn[e['c']].append(e) @@ -314,8 +326,11 @@ def nm(y): return y['d'] + (f" L{y['ln']}" if y['f'] == rel else '') # a s # a caller count of 0 says WHY where the graph knows (#1507 cluster): `←entry (http)`, `←0 resolved, 2 by name`, # `←? framework (@Scheduled)`. A framework-called method printed as `←0` read as dead code def ups(x): - if x['up'] or x['r']['method_id'] in anyup: return str(len(x['up'])) - if 'zl' not in x: x['zl'] = _graphline.zero_label(con, x['r']['method_id'], x['r']['display'].rsplit('.', 1)[-1]) + if x['up']: return str(len(x['up'])) + # every caller is in the lines just read: `←0` there read as "nothing calls it" while a Grep of the same name + # said `← 1`; the count is of callers the text does not show, and here there are none to add + if x['r']['method_id'] in anyup: return ' callers in range' + if 'zl' not in x: x['zl'] = _graphline.zero_label(con, x['r']['method_id']) return x['zl'] def line(x): r = x['r']; parts = [] @@ -417,6 +432,15 @@ def lookup(n): # PRODUCTION CALLERS ARE NAMED BEFORE TESTS (#1507): unordered, SQLite returned them by display, so two test # methods took both name slots and the three production callers hid behind the count up = c.execute("SELECT DISTINCT cr.display d, cr.is_test t FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? ORDER BY cr.is_test, cr.display LIMIT 40", (r['method_id'],)).fetchall() + via = '' + # the callers through an interface or base method, which impact lists as `calls it (via the interface)` + vb = sorted(_graphline.callers_via_base(c, [r['method_id']], cwd).get(r['method_id'], ())) + if vb: + have = {x['d'] for x in up} + more = [x for x in c.execute(f"SELECT DISTINCT display d, is_test t FROM symbols WHERE id IN ({','.join('?' * len(vb))})", vb).fetchall() if x['d'] not in have] + if more: + up = sorted(list(up) + more, key=lambda x: (x['t'], x['d']))[:40] + via = f"; {len(more)} through the interface or base" if len(more) < len(up) else '; through the interface or base' nt = sum(1 for x in up if x['t']) # by DISPLAY, like the names printed beside it: two overloads of one callee are one name to the reader dn = c.execute("SELECT count(*) n FROM (SELECT DISTINCT ce.display FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND e.callee_provenance = 'client')", (r['id'],)).fetchone()['n'] @@ -424,8 +448,8 @@ def lookup(n): tag = f" ★ {rel_[r['id']][0]} {rel_[r['id']][1]} (which you just read)" if r['id'] in rel_ else '' # the tests are counted apart only below the 40-row cap: at the cap the split is not known callers = (f"{len(up)} (" + ', '.join(x['d'].split('.')[-1] for x in up[:2]) + (', …' if len(up) > 2 else '') - + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + ")") if up \ - else _graphline.zero_label(c, r['method_id'], r['display'].rsplit('.', 1)[-1], r['is_test']) + + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + via + ")") if up \ + else _graphline.zero_label(c, r['method_id'], None, r['is_test']) out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '') + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + (f" ({n_ol[r['id']]} overloads)" if n_ol.get(r['id'], 1) > 1 else '') + tag) diff --git a/plugins/axiomcode/mcp/_fallback.py b/plugins/axiomcode/mcp/_fallback.py index 02eabf083..8eb322681 100644 --- a/plugins/axiomcode/mcp/_fallback.py +++ b/plugins/axiomcode/mcp/_fallback.py @@ -31,8 +31,9 @@ def _schema_for(ann, default): if origin in (list, typing.List): args = typing.get_args(ann) return {"type": "array", "items": {"type": _JSON_TYPE.get(args[0], "string") if args else "string"}} - if origin is not None: # Optional[X] / Union[...] -> first concrete arg + if origin is not None: # Optional[X] -> X; Union[X, Y] -> anyOf, as the SDK advertises it args = [a for a in typing.get_args(ann) if a is not type(None)] + if len(args) > 1: return {"anyOf": [_schema_for(a, default) for a in args]} return _schema_for(args[0], default) if args else {"type": "string"} return {"type": _JSON_TYPE.get(ann, "string")} @@ -51,6 +52,8 @@ def _nullable(ann, default): def _conforms(value, schema): + if "anyOf" in schema: + return any(_conforms(value, x) for x in schema["anyOf"]) if not _CHECK.get(schema["type"], lambda v: True)(value): return False return schema["type"] != "array" or all(_conforms(x, schema["items"]) for x in value) @@ -69,7 +72,8 @@ def _invalid(spec, nullable, arguments): if want is None: errors.append(f"{k}: unexpected argument") elif not (v is None and k in nullable) and not _conforms(v, want): - shown = want["type"] + (f" of {want['items']['type']}" if want["type"] == "array" else "") + shown = (" or ".join(x["type"] for x in want["anyOf"]) if "anyOf" in want else + want["type"] + (f" of {want['items']['type']}" if want["type"] == "array" else "")) errors.append(f"{k}: expected {shown}, got {type(v).__name__} {json.dumps(v)[:60]}") return f"invalid arguments for {spec['name']}: " + "; ".join(errors) if errors else None diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 80232e358..4247a873b 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """The axiomcode entry as MCP tools — one tool per subcommand, each a thin shell-out to scripts/axiomcode so the answer is exactly what the CLI prints (and stays verified there). Descriptions are short on purpose: they sit in the agent's context every turn.""" -import inspect, os, re, subprocess, sys +import inspect, os, re, subprocess, sys, typing try: from mcp.server.mcpserver import MCPServer except ImportError: @@ -32,7 +32,7 @@ PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(?]*>|'[^']*'|N(?:\|all)?(?![\w])|\d+(?![\w])|[a-z](?![\w.])|[\w*-]*[/.:*][^\s`'\"(),;\]]*))?") + r"(?:([ =])(<[^>]*>|'[^']*'|N(?:\|all)?(?![\w])|\d+(?![\w])|all(?![\w])|[a-z](?![\w.])|[\w*-]*[/.:*][^\s`'\"(),;\]]*))?") # a line of quoted source (context --source), or a site of a grep-shaped answer (`path:line: code [tag]`, whose tag # names no flag): never rewritten, since the code on it is the file's own text _CODE = re.compile(r'^\s*(\d+ )?\| |^[^\s:]+:\d+: ') @@ -45,6 +45,8 @@ def one(m): p = PARAM.get(flag) if not p: return m.group(0) if flag in SWITCH: return f"{p}=True" + (sep + val if val else '') + if val == 'all': return f'{p}="all"' # `--page all` is page="all", a string, not a name + if val == 'N|all': return f'{p}=N or {p}="all"' return f"{p}={val}" if val else p return '\n'.join(l if _CODE.match(l) else _FLAG.sub(one, l) for l in text.split('\n')) @@ -139,6 +141,24 @@ def run(args, cwd=None, timeout=900): # capped, the rest counted (scripts/ax_grep.py). The prose answer's sections, headers and explanations were most of what # an agent read, and the fan-out it complained of. full=True, or asking for what only the prose carries (the code of a # flow, test routes, a delete verdict, a later page), gives the verb's own answer, unchanged. +# A PAGE IS A NUMBER OR "all". The answers say `--page all` for the whole answer; the parameter took only an integer, so +# the hint could not be followed here, and an agent that sent page="all" was refused. page=2 and page="2" are page 2. +Page = typing.Union[int, str] + +def _page_arg(page): + p = str(page).strip().lower() if page is not None else '1' + if p == 'all': return 'all' + if not p.lstrip('-').isdigit(): + raise ToolError(f'page: expected a page number or "all", got {page!r}') + return None if int(p) == 1 else str(int(p)) + +def _paged(page): + return _page_arg(page) is not None + +def _pg(page): + v = _page_arg(page) + return ['--page', v] if v else [] + def grep(full, limit=0): return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) @@ -149,36 +169,36 @@ def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str return run(a) @srv.tool() -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False) -> str: +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`); full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" - flow = source or explain or from_.strip() or (page and page != 1) or budget - a = ['context', task, repo] + grep(full or flow) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] + flow = source or explain or from_.strip() or _paged(page) or budget + a = ['context', task, repo] + grep(full or flow) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] return run(a) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False, full: bool = False) -> str: +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain.""" - paged = page and page != 1 + paged = _paged(page) a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) return run(a) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False) -> str: - """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too).""" - prose = full or why or delete or (page and page != 1) or budget - a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False) -> str: + """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file (the CLI's --tests-only); why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too).""" + prose = full or why or delete or _paged(page) or budget + a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) return run(a) @srv.tool() -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: int = 1) -> str: +def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1) -> str: """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer.""" - a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + (['--page', str(page)] if page and page != 1 else []) + a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) return run(a) @srv.tool() -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1, full: bool = False) -> str: +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False) -> str: """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`) and the command that runs them; full=True gives the prose.""" - a = ['test-impact', repo, *files] + grep(full or why or (page and page != 1)) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) + a = ['test-impact', repo, *files] + grep(full or why or _paged(page)) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + _pg(page) return run(a) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 71b2e082c..03e6ec61f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -83,7 +83,7 @@ does not restrict. Detail: `reference/context.md`. `src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) only when page 1's strongest rows are not enough. It finds config +(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. ## changed · test-impact — from an edit diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 67e77124f..f6bd5da72 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -77,11 +77,16 @@ line and names the constructor query to run; take that suggestion before acting builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. +- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from + the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a + test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission + check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its + name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the - base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by + base class)`, and `[resolved]` only when nothing else can run there; the Read and Grep hooks count the same callers); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md index 79e272fce..81d854c0f 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ b/plugins/axiomcode/skills/axiomcode/reference/path.md @@ -87,7 +87,11 @@ have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. The conventions come from the + verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the + graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the + graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is + not one), a library method it overrides, the call sites that write its name, a library base of its type, a + decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the one module both tools read (`scripts/ax_registration.py`). - **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md index b423e757c..09039f8fc 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/schema.md +++ b/plugins/axiomcode/skills/axiomcode/reference/schema.md @@ -99,5 +99,8 @@ sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY its `class Meta:` line. - **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. + JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as + `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); + an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). - **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` names them. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 65cec6498..3f6400fe0 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -338,6 +338,33 @@ def best_scope(ranked): return (top[0], len(rows)) if score > 0 else None +def scope_spec(scope, repo='.'): + """(the scope as the index writes paths, whether it is a PATH) — how `--in` is matched (#1584). + + A scope used to match any file whose path CONTAINED it, so `--in tests` in a repository with a top-level tests/ + also took `parser/src/tests-util/…` and `graph/contests/…`: a directory name is a substring of many paths that + are not under it. A scope that names a file or directory of the repository is that path, and matches by prefix; + one that names nothing there (`pkg`, a fragment of a package name) is still matched anywhere, as it always was. + The test is the working tree, not a graph, so every language's graph reads one scope the same way.""" + s = (scope or '').strip().replace('\\', '/') + while s.startswith('./'): s = s[2:] + s = s.rstrip('/') + return s, bool(s) and os.path.exists(os.path.join(repo or '.', s)) + + +def under_scope(f, spec): + """is file `f` (repo-relative, as the index stores it) inside `spec` (a scope_spec)""" + s, is_path = spec; f = f or '' + return (f == s or f.startswith(s + '/')) if is_path else s in f + + +def scope_sql(spec, col='file'): + """(the SQL condition, its parameters) that keeps the rows of `col` inside `spec`""" + s, is_path = spec + if is_path: return f"({col} = ? OR substr({col}, 1, ?) = ?)", (s, len(s) + 1, s + '/') + return f"{col} LIKE ?", (f'%{s}%',) + + def require_scope(g, scope, terms=(), rank=None, flag=''): """Rule 1. Returns None when the scope is usable, or an exit code after printing the correction. @@ -361,7 +388,10 @@ def by_terms(item): # when no ranking was supplied. return offer("this needs to know WHERE to look: --in is required.", rank or sorted(dirs.items(), key=by_terms), terms, flag=flag) - missing = [x for x in scopes if not g.q("SELECT COUNT(*) n FROM symbols WHERE file LIKE ?", f'%{x}%')[0]['n']] + def held(x): + cond, params = scope_sql(scope_spec(x, getattr(g, 'repo', '.'))) + return g.q(f"SELECT COUNT(*) n FROM symbols WHERE {cond}", *params)[0]['n'] + missing = [x for x in scopes if not held(x)] for scope in missing[:1]: # A path that is not in the graph is usually a TYPO, and a typo is a character-level miss, not a # token-level one: `complier-core` shares exactly the same two tokens with `compiler-core` as with @@ -449,7 +479,8 @@ def ensure_graph(repo, db): # relink above took every pointer that leaves this .axiomcode/out: one still here is this repository's own try: gone = f" (.axiomcode/out/graph.sqlite points at {os.readlink(db)}, which is not there)" except OSError: gone = '' - print(f"the graph of {repo} is missing (a build was interrupted){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) + why = "it was corrupt and was moved aside" if os.path.exists(os.path.join(ax_fresh.out_dir(rr), 'corrupt')) else "a build was interrupted" + print(f"the graph of {repo} is missing ({why}){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) env = dict(os.environ, AXIOMCODE_KEEP_BASE='1') else: print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) @@ -504,6 +535,94 @@ def _start_build(argv, log, env): return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) +def usable_graph(repo, gdir): + """The graph directory a verb reads: `gdir` itself, unless its graph.sqlite is CORRUPT (a disk that filled, a copy cut + short). Then it is never read — every verb died in a Python traceback on it — but moved aside and rebuilt: the main + graph through ensure_graph (a shell waits for it; the MCP server starts it and answers at once), another language's + in the background. Until the rebuild is in, the last good graph answers (the baseline a refresh kept), and says so + on a `graph refresh:` line, which the MCP server carries into the answer. With none, the verb stops in words.""" + db = os.path.join(gdir, 'out', 'graph.sqlite') + if not os.path.exists(db): return gdir + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + why = ax_fresh.graph_corrupt(db) + if not why: return gdir + return _corrupt(repo, gdir, db, why) + + +def _corrupt(repo, gdir, db, why): + import ax_fresh + rr = os.path.realpath(repo) + lang = ax_fresh.graph_lang() + # a graph someone placed with AXIOMCODE_GRAPH is theirs: said, never moved or rebuilt. One of this repository's own + # language graphs (the dispatcher sets AXIOMCODE_GRAPH to it, with AXIOMCODE_GRAPH_LANG) is ours to repair + placed = os.environ.get('AXIOMCODE_GRAPH') + own = not placed or (lang and os.path.realpath(placed) == os.path.realpath(os.path.join(rr, '.axiomcode', 'lang', lang))) + what = f"the {lang} graph" if lang else "the graph" + if not own: + _stop(f"{what} at {db} is corrupt ({why}); AXIOMCODE_GRAPH placed it, so it is not rebuilt here — rebuild it where it was built") + moved = ax_fresh.quarantine(rr, db, why) + print(f"graph refresh: {what} at {db} is corrupt ({why}) — " + (f"moved aside to {moved} and rebuilding it" if moved else "rebuilding it"), + file=sys.stderr, flush=True) + if not lang: + if ensure_graph(repo, db) and os.path.exists(db) and not ax_fresh.graph_corrupt(db): return gdir + elif not ax_fresh.building(rr): + _rebuild_in_background(rr) + good = ax_fresh.last_good_graph(rr) + if good: + print(f"graph refresh: until the rebuild is in, this answer comes from the last good graph ({good}), which predates " + f"the edits since it was built", file=sys.stderr, flush=True) + return good + _stop(_BUILD_NOTE[-1] if _BUILD_NOTE else + f"{what} at {db} was corrupt and there is no earlier graph to answer from; a rebuild is " + f"{'running' if ax_fresh.building(rr) else 'needed'} — ask again when it is done, or run `axiomcode index {repo}`") + + +def _stop(msg): + """the verb's refusal, as the verbs' own die() gives it: the words on stdout, exit 2""" + print(msg); sys.exit(2) + + +def _rebuild_in_background(repo): + """start a detached build of the repository, keeping the baseline, as a repair does (another language's graph was + corrupt; the build rebuilds every language, and the corrupt marker keeps it from calling the tree up to date)""" + build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build') + if not os.path.exists(build): return + env = dict(os.environ, AXIOMCODE_KEEP_BASE='1', AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON='a corrupt graph') + for k in ('AXIOMCODE_GRAPH', 'AXIOMCODE_GRAPH_LANG', 'AXIOMCODE_FANOUT', 'AXIOMCODE_LANG'): env.pop(k, None) + try: + log = open(os.path.join(repo, '.axiomcode', 'refresh.log'), 'a') + kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) + subprocess.Popen([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, + close_fds=True, env=env, **kw) + except OSError: pass + + +def on_corrupt(repo, db): + """A graph that reads cleanly when opened can still hold a malformed page that only a query reaches. Installed by a + verb once its graph is open: an sqlite error that escapes the verb is checked with quick_check, and when the graph is + corrupt the verb says so and moves it aside for a rebuild instead of printing a traceback. Any other error is + reported as it was.""" + prev = sys.excepthook + def hook(t, e, tb): + import sqlite3 + if isinstance(e, sqlite3.DatabaseError) and os.path.exists(db): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + why = ax_fresh.graph_corrupt(db, thorough=True) + if why: + rr = os.path.realpath(repo); lang = ax_fresh.graph_lang() + placed = os.environ.get('AXIOMCODE_GRAPH') + own = not placed or (lang and os.path.realpath(placed) == os.path.realpath(os.path.join(rr, '.axiomcode', 'lang', lang))) + moved = ax_fresh.quarantine(rr, db, why) if own else '' + if own and not ax_fresh.building(rr): _rebuild_in_background(rr) + print(f"{'the ' + lang + ' graph' if lang else 'the graph'} at {db} is corrupt ({why}) — " + + (f"moved aside to {moved}; it is being rebuilt, ask again in a minute" if moved else + "rebuild it where it was built" if not own else f"run `axiomcode index {repo}` to rebuild it"), + file=sys.stderr, flush=True) + sys.stdout.flush(); os._exit(1) # SystemExit raised in an excepthook is itself reported + prev(t, e, tb) + sys.excepthook = hook + + def _published(repo): """the main graph is out while the build goes on solving the repository's other languages: the answer is given now, and names on stderr (a `graph refresh:` line, which the MCP server carries into the answer) what it cannot see yet""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index a46d39e45..33abf6379 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -438,6 +438,57 @@ def relink(repo, locked=False, say=True): if say: print(msg, file=sys.stderr) return msg +def graph_corrupt(db, thorough=False): + """why the graph at db cannot be read ('' when it can). A disk that filled mid-write, a copy cut short or a crash can + leave graph.sqlite malformed, and every verb then died in a Python traceback (`file is not a database`, `database disk + image is malformed`) instead of saying what was wrong. The probe is cheap (the schema is read, as every verb reads it + first); only when it fails, or with `thorough` (a verb that hit an error mid-answer), is quick_check asked, since on a + large graph it reads every page. An empty file is not corrupt: it is a graph that was never indexed.""" + import sqlite3 + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + except sqlite3.Error as e: return str(e) + try: + try: + con.execute("SELECT count(*) FROM sqlite_master").fetchone() + if not thorough: return '' + except sqlite3.OperationalError as e: + if 'locked' in str(e) or 'unable to open' in str(e): return '' # busy or gone, not malformed + first = str(e) + except sqlite3.DatabaseError as e: first = str(e) + else: first = '' + try: + bad = [r[0] for r in con.execute("PRAGMA quick_check(3)").fetchall() if r[0] != 'ok'] + except sqlite3.DatabaseError as e: return first or str(e) + return first or '; '.join(bad) + finally: con.close() + +def quarantine(repo, db, why): + """move a corrupt graph out of the way so the next build replaces it rather than reads it, and mark the repository so + that build cannot take the unchanged files for an up-to-date graph (axiomcode-build reads .axiomcode/out/corrupt). A + pointer's TARGET is moved and the pointer left dangling, which every verb already treats as a graph to repair (a + rebuild that keeps the baseline). Returns where the corrupt file went, or '' when it could not be moved.""" + real = os.path.realpath(db); dst = real + '.corrupt' + try: + os.replace(real, dst) + for ext in ('-wal', '-shm', '-journal'): + if os.path.exists(real + ext): os.replace(real + ext, dst + ext) + except OSError: dst = '' + try: + os.makedirs(out_dir(repo), exist_ok=True) + with open(os.path.join(out_dir(repo), 'corrupt'), 'w') as f: f.write(f"{db}: {why}\n") + except OSError: pass + return dst + +def last_good_graph(repo): + """a graph directory that can still answer when the current graph is corrupt: the baseline graph a refresh kept + (.axiomcode/base, or base/lang/ for another language), when it reads cleanly. It describes an earlier tree, so + an answer from it must say so. None when there is none.""" + root = os.path.join(repo, '.axiomcode', 'base') + d = os.path.join(root, 'lang', graph_lang()) if graph_lang() else root + db = os.path.join(d, 'out', 'graph.sqlite') + return d if os.path.isfile(db) and os.path.getsize(db) and not graph_corrupt(db) else None + def graph_lang(): """the language whose graph a verb reads when it is not the main one (AXIOMCODE_GRAPH_LANG, set by the dispatcher as it asks each language's graph in turn), else ''""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py index 47dc64791..6c33ad9ee 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py @@ -89,8 +89,28 @@ def main(argv): if all(n[2] == 3 for n in named): # no graph has anything to say: the main one says so sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]); return 0 named = [n for n in named if n[2] != 3] + named, outside = by_scope(named) named, others = by_landing(named) + # what each graph found nothing by (ax_text.py): searched once, below, and never shown as a graph's stderr + import ax_text + marks = [] + for i, n in enumerate(named): + err, got = ax_text.take(n[4]); marks.append(got); named[i] = (*n[:4], err) answered = [n for n in named if n[2] == 0] + # a graph that could not answer at all keeps its `graph refresh:` lines (a corrupt graph moved aside and being rebuilt, + # ax_contract.usable_graph): dropped with its refusal, the answer read as whole while a language was missing from it + notes = ''.join(l + '\n' for n in named if n[2] != 0 and answered for l in n[4].splitlines() if l.startswith('graph refresh:')) + text = '' + quoted = [m for ms in marks for m in ms if m.get('why') != 'unresolved'] + if quoted: + # a string asked about, or quoted in a task, is searched once for the repository, whichever graphs answered + text = ax_text.block(repo, list(dict.fromkeys(a for m in quoted for a in m['asked'])), quoted[0].get('scope'), + quoted[0].get('why'), quoted[0].get('rows') or ax_text.ROWS) + elif not answered and marks and all(marks): + # every graph refused and every one of them found nothing by the name: ONE [text] block, not one per language. + # A graph that refused for another reason (a scope it does not hold, an ambiguous kind) withholds it + asked = [a for a in marks[0][0]['asked'] if all(any(a in m['asked'] for m in ms) for ms in marks)] + text = ax_text.block(repo, asked, marks[0][0].get('scope')) if asked else '' if not answered: # a graph that does not hold the --in scope says only that; the graph that holds it has the refusal that # matters (nothing under it matches, or the name is not there), so a scope in one language is judged by it @@ -109,20 +129,30 @@ def main(argv): print(json.dumps(base, indent=1)) else: print(json.dumps(base, indent=1) if not isinstance(base, str) else base, end='' if isinstance(base, str) else '\n') - sys.stderr.write(''.join(n[4] for n in answered)) + sys.stderr.write(''.join(n[4] for n in answered) + notes) return 0 show = answered or named if not answered and len({(n[3], n[4]) for n in named}) == 1: # the same refusal from every graph: once print(f"══ {', '.join(n[0] for n in named)} graph{'s' if len(named) > 1 else ''} ══"); sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]) + if text: sys.stdout.write('\n' + text) return named[0][2] + # an --in no graph holds is dropped by every graph for the root, and each says so: the line is said once, on top + gone = [o.split('\n', 1)[0] for _, _, _, o, _ in show if o.startswith(ax_text.SCOPE_GONE)] + if len(show) > 1 and gone: + print(gone[0]); show = [(*n[:3], n[3][len(n[3].split('\n', 1)[0]) + 1:] if n[3].startswith(ax_text.SCOPE_GONE) else n[3], n[4]) for n in show] for i, (lang, is_main, rc, out, err) in enumerate(show): if len(show) > 1 or not is_main: print(('' if i == 0 else '\n') + f"══ {lang} graph ══" + ('' if is_main else f" (.axiomcode/lang/{lang})"), flush=True) sys.stdout.write(out); sys.stdout.flush(); sys.stderr.write(err); sys.stderr.flush() + if notes: sys.stderr.write(notes); sys.stderr.flush() + if text: sys.stdout.write('\n' + text) if answered and others: print(f"\nthe --from name is also declared in the {', '.join(others)} graph(s), where its flow reaches " "half as many of the task's words or fewer; --lang asks one of them") + if answered and outside: + print(f"\nthe name is also declared in the {', '.join(outside)} graph(s), none of it under the --in scope; " + "drop --in, or --lang , to ask about those") return 0 if answered else named[0][2] @@ -130,6 +160,27 @@ def main(argv): NOT_HELD = 'no indexed file has ' # ax_contract.require_scope: the --in path is not in this graph at all +SCOPED = 'axiomcode-scope-declared: ' # axiomcode-impact: whether the target's declarations lie under --in + + +def by_scope(named): + """(the answers, the languages left out) — `impact --in ` in every graph (#1584). + + Every graph that declares the name answered, each for its own declarations, so a scope meant to pick the one + under it came back with other languages' declarations from elsewhere first. A graph with a declaration under the + scope answers; one whose declarations all lie outside it is left out and named — when some graph has one inside. + """ + flag = {} + for i, n in enumerate(named): + err = n[4].splitlines(keepends=True) + for l in err: + if l.startswith(SCOPED): flag[i] = l[len(SCOPED):].strip() == '1' + named[i] = (*n[:4], ''.join(l for l in err if not l.startswith(SCOPED))) + if not any(flag.get(i) and n[2] == 0 for i, n in enumerate(named)): return named, [] + drop = {i for i, n in enumerate(named) if n[2] == 0 and flag.get(i) is False} + return [n for i, n in enumerate(named) if i not in drop], [named[i][0] for i in sorted(drop)] + + def by_landing(named): """(the answers, the languages left out) — `context --from ` in every graph, the language the task lands in first. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index c838e9042..8b202a7af 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -16,24 +16,34 @@ import atexit, collections, io, re, sys PAGE_BUDGET = 2000 # tokens per page, at ~4 characters a token -WEAK_ROW = re.compile(r'^\s{4}\[(by name|text|alongside|in scope)\]' - r'|^\s{6}\s?\d+ hop\(s\) |^\s{6}… \+') # low-certainty users, and entry-point lists, go last +# low-certainty users go last, under their own heading; so do the entry-point lists (a sub-heading and its hop rows), +# which are not low-certainty and are headed as what they are +LOW_ROW = re.compile(r'^\s{4}\[(by name|text|alongside|in scope)\]') +ENTRY_ROW = re.compile(r'^\s{6}\s?\d+ hop\(s\) |^\s{6}… \+|^\s{4}entry points \(') +WEAK_ROW = re.compile(LOW_ROW.pattern + '|' + ENTRY_ROW.pattern) QUALIFIER = re.compile(r'^\s{0,2}(verified:|bound:|note:|next:|through a call the engine could not resolve|outside the graph:)') LABEL = (('reads or uses it', 'users'), ('produces or writes it', 'writers'), ('must change with it', 'contract'), ('reaches those', 'entry points'), ('bound from outside the source', 'non-source name matches'), ('tests:', 'tests'), ('depends on', 'dependencies'), ('where the work is', 'files')) +RUNG = re.compile(r'^\s+\[([^\]]+)\]') +COUNTED = re.compile(r'^\s+\[[^\]]+\] |^\s+\d+ hop\(s\) ') +# A VERB MAY MARK THE ROWS ITS DEFAULT VIEW HIDES (HIDE) AND THE LINES THAT SUMMARISE THEM (SUMM), e.g. impact's +# "… +54 more in 21 file(s)". Page 1 is the default view: every unmarked line, summaries included. Page 2 on are the +# rows page 1 did not print, the hidden ones included, and no summary: a later page that repeated "… +54 more" +# under nine new rows was the answer an agent got for asking for page 2 (rated not-good in 10 of 11 calls). +HIDE, SUMM = '\x01', '\x02' +CAPTURING = False # set by install(): a verb marks rows only when this pager reads its output -def paginate(text, page, budget, budget_flag='--budget'): +def _views(text): lines = text.rstrip('\n').split('\n') - if page == 'all' or len(text) <= budget * 4: - return text - # AN EXPLANATION IS NOT SPLIT. Its flow is a reading order, sized when it is built (bounded steps, a code budget); - # split, the second half is what nobody reads — no measured run ever asked for page 2 — and it holds the later - # steps of the mechanism. Kept whole up to half a page over, never beyond. - if 'how it runs —' in text and len(text) <= budget * 6: - return text - head = [] + capped = [l[1:] if l.startswith(SUMM) else l for l in lines if not l.startswith(HIDE)] + full = [l[1:] if l.startswith(HIDE) else l for l in lines if not l.startswith(SUMM)] + return capped, full + + +def _parse(lines): + lines = list(lines); head = [] # a note printed BEFORE the answer says how to read all of it (a name that merged two declarations, a name that is # a field and a method): it stays on top of every page instead of sinking into the footer with the qualifiers while lines and lines[0].startswith('note:'): @@ -41,13 +51,81 @@ def paginate(text, page, budget, budget_flag='--budget'): while lines and (lines[0].startswith('change:') or (head and lines[0].startswith(' ') and not lines[0].startswith(' '))): head.append(lines.pop(0)) quals = [l for l in lines if QUALIFIER.match(l)] - body = [l for l in lines if not QUALIFIER.match(l)] sections, cur = [], None # a line at column 0 opens a section - for l in body: + for l in (l for l in lines if not QUALIFIER.match(l)): if not l.startswith(' ') or cur is None: cur = [l, []]; sections.append(cur) else: cur[1].append(l) + return head, quals, sections + + +def _name(title): + return re.split(r' \(|:', title, maxsplit=1)[0].strip() or title.strip() + + +def _layout(sections, room, keep_empty): + """pages of (section index, part, line): each section's strong rows first, then every section's weak rows. A + section with no rows is kept (its heading is its content) only when keep_empty; on a later page it is dropped.""" + parts = [] + for i, (title, rows) in enumerate(sections): + keep = [r for r in rows if not WEAK_ROW.match(r)] + if keep or (keep_empty and not rows): parts.append((i, 'first', keep)) + for i, (title, rows) in enumerate(sections): + low = [r for r in rows if LOW_ROW.match(r)] + ent = [r for r in rows if ENTRY_ROW.match(r)] + if low: parts.append((i, 'low', low)) + if ent: parts.append((i, 'entry', ent)) + pages, cur, used = [], [], 0 + for i, part, rows in parts: + tlen = len(sections[i][0]) + 40 + if cur and used + tlen + (len(rows[0]) + 1 if rows else 0) > room: + pages.append(cur); cur, used = [], 0 + used += tlen + if not rows: cur.append((i, part, None)) + for r in rows: + if used + len(r) + 1 > room and cur: + pages.append(cur); cur, used = [], tlen + cur.append((i, part, r)); used += len(r) + 1 + if cur: pages.append(cur) + return pages + + +def _render(sections, items, whole_title): + """the lines of one page: a heading over each run of rows. A section's own heading (the WHOLE answer's counts) + heads its first rows on page 1; anywhere else the heading counts the rows printed under it, so the numbers on a + page add up to what the page shows.""" + out, run = [], [] + def flush(): + if not run: return + i, part = run[0][0], run[0][1] + rows = [r for _, _, r in run if r is not None] + title = sections[i][0] + if not (part == 'first' and whole_title(i)): + n = sum(1 for r in rows if COUNTED.match(r)) or len(rows) + rungs = collections.Counter(m.group(1) for r in rows for m in [RUNG.match(r)] if m) + what = f"{n} row(s)" + (': ' + ', '.join(f"{c} {k}" for k, c in rungs.most_common()) if rungs else '') + title = _name(title) + {'first': f" (continued; on this page: {what})", + 'low': f" — low-certainty rows (on this page: {what})", + 'entry': f" — entry points (continued; on this page: {what})"}[part] + out.append(title); out.extend(rows); run.clear() + for it in items: + if run and (it[0], it[1]) != (run[0][0], run[0][1]): flush() + run.append(it) + flush() + return out + + +def _rows(lines): + return sum(1 for l in lines if l.startswith(' ')) + + +def paginate(text, page, budget, budget_flag='--budget'): + capped, full = _views(text) + ctext = '\n'.join(capped) + '\n' + if page == 'all': + return '\n'.join(full) + '\n' + head, quals, sections = _parse(capped) def totals_for(shown): out = [] @@ -57,42 +135,54 @@ def totals_for(shown): out += [' ' + r.strip() for r in rows if r.lstrip().startswith(('how sure each route', 'by hop:'))] return out - strong, weak = [], [] - for title, rows in sections: - keep = [r for r in rows if not WEAK_ROW.match(r)] - low = [r for r in rows if WEAK_ROW.match(r)] - if keep or not low: strong.append((title, keep)) - if low: weak.append((title + ' — low-certainty rows', low)) fixed = sum(len(l) + 1 for l in head + quals) + sum(len(l) + 1 for l in totals_for(set())) + 400 room = max(1500, budget * 4 - fixed) - pages, cur, used = [], [], 0 - for title, rows in strong + weak: - for i, l in enumerate([title] + rows): - if used + len(l) + 1 > room and cur: - pages.append(cur); cur, used = [], 0 - if i > 0: cur.append(title + ' (continued)'); used += len(title) + 13 - cur.append(l); used += len(l) + 1 - if cur: pages.append(cur) - # AN ANSWER WITH NO ROWS BEYOND ITS FIRST PAGE IS NOT PAGED: a refusal or a note list longer than a page came back as - # "page 1 of 2 ... 1 more page, 0 rows", a footer promising more of an answer that had none - if len(pages) > 1 and page == 1 and not any(l.startswith(' ') for pg in pages[1:] for l in pg): - return text - n = len(pages) + # PAGE 1. An answer that fits is printed as it always was. AN EXPLANATION IS NOT SPLIT: its flow is a reading + # order, sized when it is built; split, the second half is what nobody reads. Kept whole up to half a page over. + whole = len(ctext) <= budget * 4 or ('how it runs —' in ctext and len(ctext) <= budget * 6) + first = None if whole else _layout(sections, room, True) + # an answer with no rows beyond its first page is not paged: a refusal or a note list longer than a page came back + # as "page 1 of 2 ... 1 more page, 0 rows", a footer promising more of an answer that had none + if first and len(first) > 1 and not any(r and r.startswith(' ') for pg in first[1:] for _, _, r in pg): + whole, first = True, None + p1 = first[0] if first else None + # LATER PAGES: every row page 1 did not print, in the order of the full answer, under the section it belongs to + on_p1 = collections.Counter((sections[i][0], r) for i, _, r in p1 if r) if p1 else \ + collections.Counter((t, r) for t, rows in sections for r in rows) + _h, _q, fsections = _parse(full) + rest = [] + for title, rows in fsections: + left = [] + for r in rows: + if on_p1[(title, r)] > 0: on_p1[(title, r)] -= 1 + else: left.append(r) + rest.append([title, left]) + later = _layout(rest, room, False) if any(rows for _, rows in rest) else [] + n = 1 + len(later) + if page == 1 and (whole or n == 1): + return ctext if whole else '\n'.join(head + _render(sections, p1, lambda i: True) + [''] + quals) + '\n' if page < 1 or page > n: + if n == 1: + nr = _rows(capped) + return (f"page {page} does not exist: this answer has 1 page, and the answer without --page printed all of it" + + (f" ({nr} row(s))" if nr else '') + "; nothing follows it\n") return f"page {page} does not exist: this answer has {n} page(s) at {budget_flag} {budget}\n" - body = pages[page - 1]; rest = totals_for({l for l in body if not l.startswith(' ')}) - out = head + [f'page {page} of {n}:'] + body + ([''] + ['also in this answer (counts are for the whole answer):'] + rest if rest else []) + [''] + quals - - def short(t): - weak_ = t.endswith('low-certainty rows') - name = next((v for k, v in LABEL if t.startswith(k)), t.split(' (')[0].split(':')[0].strip()[:40]) - return ('[by name]/[text] ' if weak_ and name == 'users' else '') + name - left = pages[page:] - held = list(dict.fromkeys(short(re.sub(r'\s+\(continued\)$', '', l)) for pg in left for l in pg - if l and not l.startswith((' ', '(')))) - rows_left = sum(1 for pg in left for l in pg if l.startswith(' ')) - nxt = (f"{len(left)} more page(s) left, {rows_left} row(s): {', '.join(held) or 'the rest of the rows above'} — ask for them with " - f"page={page + 1} (MCP) or --page {page + 1} (CLI), or all of it with --page all") if left else "this is the last page" + if page == 1: + body = _render(sections, p1, lambda i: True) + else: + body = _render(rest, later[page - 2], lambda i: False) + shown = {l for l in body if not l.startswith(' ')} + rest_totals = totals_for(shown) + out = head + [f'page {page} of {n}:'] + body + ([''] + ['also in this answer (counts are for the whole answer):'] + rest_totals if rest_totals else []) + [''] + quals + + def short(i, part, secs): + name = next((v for k, v in LABEL if secs[i][0].startswith(k)), _name(secs[i][0])[:40]) + return ('[by name]/[text] ' if part == 'low' and name == 'users' else '') + name + left = later[page - 1:] + held = list(dict.fromkeys(short(i, part, rest) for pg in left for i, part, _ in pg)) + rows_left = sum(1 for pg in left for _, _, r in pg if r and r.startswith(' ')) + nxt = (f"{len(left)} more page(s) left, {rows_left} row(s): {', '.join(held) or 'the rest of the rows above'} — ask for " + f"the next with --page {page + 1}, or all of it with --page all") if left else "this is the last page" out.append(f"page {page} of {n} (~{budget} tokens a page): {nxt}; {budget_flag} N changes the page size;" " narrow instead with --in , --depth N or --tests-only") return '\n'.join(out) + '\n' @@ -204,7 +294,7 @@ def next_test_impact(text): # When a text tier adds the tests that load a changed fixture, it prints the command(s) for both after # "with the tests above:", and the first command alone left those tests out of the step an agent takes: the # LAST such block wins, with every command that continues it - ms = list(re.finditer(r'^\s*(with the tests above: )?((?:\(cd \S+ && )?(?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pnpm|yarn|bun|node|tsx|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M)) + ms = list(re.finditer(r'^\s*(with the tests above: )?((?:\(cd \S+ && )?(?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pnpm|yarn|bun|node|tsx|pytest|python -m pytest|python manage\.py test|python -m unittest|python(?= \S+\.py$)|dotnet|go) [^\n]+)$', text, re.M)) if not ms: return '' last = max((i for i, m in enumerate(ms) if m.group(1)), default=0) cmds = [m.group(2).strip() for m in ms[last:]] @@ -233,6 +323,8 @@ def install(verb): i = argv.index(flag); budget = int(argv[i + 1]); del argv[i:i + 2] if '--json' in argv: return + global CAPTURING + CAPTURING = True real, buf = sys.stdout, io.StringIO() sys.stdout = buf diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 028c113df..cf31d10a2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -82,28 +82,7 @@ def registrations(q, site_file=None): # the cap and joins two unrelated tests. The DEPENDENT row is still true and still printed — a test that mounts a # handler does depend on it — so only the key is withheld. tf = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} if _has(q, 'symbols') else set() - out = {} - verbs = {} # (file, line) -> {HTTP verb: first column}, for the wording below - for name, site_kind, fp, a, b, col in q("""SELECT callee_name, kind, file_path, start_line, end_line, end_column FROM call_sites - WHERE callee_name IS NOT NULL AND start_line > 0"""): - f = sf(fp); b = b or a - if b < a or b - a > MAX_SITE_SPAN: - continue - if site_kind == 'DECORATOR_CALL': - continue # a decoration is reported as a decoration, not as a registration - short = (name or '').split('.')[-1] - paths = [v for l in range(a, b + 1) for v in lits.get((f, l), ()) if isinstance(v, str) and v.startswith('/')] - if short.lower() in ROUTE_VERB and paths: - kind, key = 'route', ('' if f in tf else paths[0]) - why = f'registered as a {short.upper()} route "{paths[0]}" here — the router calls it, no call site does' - else: - continue # no evidence that this call does anything with a declaration named here - for l in range(a, b + 1): - # a route beats a plain callback on the same line: `router.use('/x', wrap(handler))` is a route site - if (f, l) not in out or kind == 'route': - out[(f, l)] = (kind, key, why) - if short.lower() in HTTP_VERB and (l == b or a == b): - vs = verbs.setdefault((f, l), {}); vs[short.upper()] = min(vs.get(short.upper(), col or 0), col or 0) + out, verbs, _own = _route_links(q, sf, lits, tf) # `router.route('/').get(h)` is two route-shaped calls on one line, and `route` said nothing about the method: the # line's HTTP verbs name it, in the order written. A chain registering two (`.get(a).post(b)`) names both, because # a line is all a reference records and it cannot say which argument list the handler sat in @@ -115,6 +94,83 @@ def registrations(q, site_file=None): return sorted((ref_at.get((f, l), ''), f, l, k, key, w) for (f, l), (k, key, w) in out.items()) +def route_site_lines(q, site_file=None): + """{call_site_id: line} — the line a CHAINED route link's own part is written on, for each link after the first. + + A call site's start line is where its receiver starts, so the engine places the handler of `.post('/c', h3)` in + `router.get('/a', h1)\\n.get('/b', h2)\\n.post('/c', h3)` on the chain's first line, where the registration rows say + GET "/a". The `calls` fact and the SQL rows place an edge at one of these sites on this line instead, the line + `registrations()` labels with the link's own verb and path. Only route links move; every other site keeps its line. + """ + if not _has(q, 'call_sites'): + return {} + lits = {} + if _has(q, 'literals'): + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + lits.setdefault((f, l), []).append(v) + return _route_links(q, site_file or (lambda x: x), lits, set())[2] + + +def _route_links(q, sf, lits, tf): + """({(file, line): (kind, key, why)}, {(file, line): {HTTP verb: column}}, {call_site_id: own line}) for every + route registration site. + + A CHAINED REGISTRATION `router.get('/a', h1).get('/b', h2).post('/c', h3)` is three call sites that all START + where `router` starts: each one's span covers its receiver, i.e. every call before it in the chain. Reading a + site's verb and path over its whole span gave each link the first link's path, and whichever link was read last + claimed every line. A link's OWN part begins where its receiver ends, so the chain is walked innermost first and + each link takes the first path its own part holds that an earlier link has not taken; a link with no path of its + own (`router.route('/p').get(h).put(h2)`) keeps its receiver's, which is what that API means. + """ + sites = [] + for sid, name, site_kind, fp, a, ac, b, bc in q("""SELECT id, callee_name, kind, file_path, start_line, start_column, + end_line, end_column FROM call_sites + WHERE callee_name IS NOT NULL AND start_line > 0"""): + b = b or a + if b < a or b - a > MAX_SITE_SPAN: + continue + if site_kind == 'DECORATOR_CALL': + continue # a decoration is reported as a decoration, not as a registration + sites.append((sf(fp), a, ac, b, bc, (name or '').split('.')[-1], sid)) + chains = {} + for s in sites: + # without a start column two calls on one line cannot be told apart from a chain: each is its own chain + chains.setdefault((s[0], s[1], s[2]) if s[2] is not None else s, []).append(s) + out, owned, own_line = {}, set(), {} + verbs = {} # (file, line) -> {HTTP verb: first column}, for registrations' wording + for links in chains.values(): + links.sort(key=lambda s: (s[3], s[4] or 0)) + taken, prev_end, prev_path = set(), None, None + for f, a, _ac, b, col, short, sid in links: + lo = a if prev_end is None else prev_end + own = [(l, i, v) for l in range(lo, b + 1) for i, v in enumerate(lits.get((f, l), ())) + if isinstance(v, str) and v.startswith('/') and (l, i) not in taken] + if own: + l0, i0, path = own[0] + taken.add((l0, i0)) + else: + # no path of its own: the link's own part starts on the line after its receiver's, when it has one + l0, path = (min(lo + 1, b) if prev_end is not None else a), prev_path + chained, prev_end, prev_path = prev_end is not None, b, path + if short.lower() not in ROUTE_VERB or path is None: + continue # no evidence that this call does anything with a declaration named here + if chained: + own_line[sid] = l0 + kind, key = 'route', ('' if f in tf else path) + why = f'registered as a {short.upper()} route "{path}" here — the router calls it, no call site does' + # a line shared by two links belongs to the verb whose own part starts on it, else to the outer link: on a + # chain written on one line nothing tells the links apart, and the line keeps the last verb it always had. + # `route(p)` registers no handler, so it never holds a line against the verbs chained onto it + owns = short.lower() != 'route' and (not chained or l0 > lo) + for l in range(lo, b + 1): + if (f, l) not in owned: + out[(f, l)] = (kind, key, why) + if owns and l == l0: owned.add((f, l)) + if short.lower() in HTTP_VERB and (l == b or lo == b): + vs = verbs.setdefault((f, l), {}); vs[short.upper()] = min(vs.get(short.upper(), col or 0), col or 0) + return out, verbs, own_line + + # A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a # field, not a method, so it has no call edge of its own to carry the route wording. What both backends join instead # (`const_route` in dl/impact.dl, graph_sql.direct_for_field) is two facts read from the source at the call sites the @@ -360,6 +416,18 @@ def _has(q, t): # Every one of those is "the framework will dispatch to this declaration when someone writes this string", which is # exactly what the join needs. The kind is read from the key rather than from a list of decoration names: a key that # begins with `/` is a route, anything else is a key, and no framework is named anywhere in this function. +def decoration_key_strings(text): + """the strings a decoration's text registers its declaration under, sorted: every quoted string in it but prose. + A STRING WITH A SPACE IN IT IS PROSE, NOT A KEY: `@widgets.doc("Endpoint to list the widgets")`, `@Operation(summary = "List + the orders")`, a cron expression, a query. No route, command, signal or table name is written with one, and read as + a key the description was printed as what the framework dispatches on.""" + out = set() + for a, b in re.findall(r'"([^"]{1,120})"|\'([^\']{1,120})\'', text or ''): + key = a or b + if key and not re.search(r'\s', key): out.add(key) + return sorted(out) + + def decoration_keys(q, site_file=None): """[(decl, file, line, kind, key, why)] — a declaration registered under a string by its own decoration.""" if not _has(q, 'decorations'): @@ -378,10 +446,7 @@ def decoration_keys(q, site_file=None): if owner in tests: continue short = (name or '').split('.')[-1] - for key in sorted(set(re.findall(r'"([^"]{1,120})"|\'([^\']{1,120})\'', text or ''))): - key = key[0] or key[1] - if not key: - continue + for key in decoration_key_strings(text): kind = 'route' if key.startswith('/') else 'key' why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py new file mode 100644 index 000000000..101f23132 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -0,0 +1,245 @@ +"""[text]: what a search by hand finds, for a question the graph has no declaration for. + +NEVER ANSWER WORSE THAN GREP. A name no graph declares (a message, an environment variable, a key written in a YAML, a +string an issue quotes) was refused with "nothing named X" and nothing else, and the agent asked it then searched the +tree by hand: most of the searches that bypassed the graph were exactly that. The search is cheap and the tool knows +more than grep does about each line it finds: which declaration holds it, or that no graph reads that file at all. + +So a refusal now carries the text matches, and every one of them is labelled `[text]`: a line that WRITES the name, +never a call edge, never counted as a caller, and never an exit status of 0 (the verb still refused). The containing +declaration is looked up in every graph of the repository, so a line in another language's file is placed too. + +In a repository in several languages every graph refuses on its own (ax_langs.py). A verb that is one of several +(AXIOMCODE_FANOUT) prints no text block: it writes MARK and what it would have searched for on stderr, and the +dispatcher prints ONE block when no graph answered, instead of the same lines once per language. +""" +import json, os, re, subprocess, sys + +MARK = 'axiomcode-text: ' +SCOPE_GONE = 'scope: no indexed file in any graph has ' # the line that says an --in was dropped for the root +ROWS = 12 # rows printed; the count of the rest is printed with them +PER_FILE = 3 # rows from one file before the next file gets a turn +_FILE_LIKE = re.compile(r'^[\w./-]*\.[A-Za-z][\w]{0,7}$|/') +_FILE_LINE = re.compile(r':\d+(:\d+)?$') + + +def graph_dbs(repo): + """every graph.sqlite of the repository, the main one first""" + import ax_langs + return [os.path.join(d or os.path.join(repo, '.axiomcode'), 'out', 'graph.sqlite') for _, d in ax_langs.graphs(repo)] + + +def _con(db): + import sqlite3 + try: return sqlite3.connect(f"file:{db}?mode=ro", uri=True) + except Exception: return None + + +def held(repo, scope): + """True when some graph of the repository holds a file with `scope` in its path""" + for db in graph_dbs(repo): + c = _con(db) + if not c: continue + try: + if c.execute("SELECT 1 FROM symbols WHERE file LIKE ? LIMIT 1", (f'%{scope}%',)).fetchone(): return True + except Exception: pass + finally: c.close() + return False + + +def declared(repo, name): + """True when some graph declares a symbol named `name`""" + for db in graph_dbs(repo): + c = _con(db) + if not c: continue + try: + if c.execute("SELECT 1 FROM symbols WHERE name = ? LIMIT 1", (name,)).fetchone(): return True + except Exception: pass + finally: c.close() + return False + + +def needles(asked): + """[(needle, whole word?)] to try in turn for a name as it was asked: the whole of it, then (for Owner.member) the + member alone. A quoted string is searched as written; a file:line is not searched (the graph answers a line)""" + s = asked.strip() + if len(s) > 1 and s[0] in '"\'`' and s[-1] == s[0]: return [(s[1:-1], False)] if s[1:-1] else [] + if _FILE_LINE.search(s) or s in ('*', ''): return [] + s = re.sub(r'\([^()]*\)$', '', s) # Owner.m(p): the name, not the signature + word = lambda x: bool(re.fullmatch(r'\w+', x)) + out = [(s, word(s))] + last = re.split(r'[.#:$]', s)[-1] + if last and last != s and word(last) and len(last) >= 3: out.append((last, True)) + return out + + +def _git(repo, args): + try: + r = subprocess.run(['git', '-C', repo] + args, capture_output=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): return None + return r.stdout.decode('utf-8', 'replace') if r.returncode in (0, 1) else None + + +def _walk(repo): + import ax_fresh + for root, dirs, files in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ax_fresh.PRUNE_ALL and not d.startswith('.')] + rel = os.path.relpath(root, repo).replace(os.sep, '/') + for fn in files: yield fn if rel == '.' else f"{rel}/{fn}" + + +def files(repo): + """every file of the repository a search by hand would look at: git's (tracked and untracked, not ignored), else + the refresher's pruned walk""" + out = _git(repo, ['ls-files', '-co', '--exclude-standard']) + fs = out.splitlines() if out is not None else list(_walk(repo)) + return [f for f in fs if f and not f.startswith('.axiomcode/')] + + +def grep(repo, needle, word): + """[(file, line, text)] of every line holding `needle` (as a whole word when `word`), outside .axiomcode""" + out = _git(repo, ['grep', '-n', '-I', '--no-color', '--untracked', '-F'] + (['-w'] if word else []) + + ['-e', needle, '--', '.', ':(exclude).axiomcode']) + hits = [] + if out is not None: + for l in out.splitlines(): + m = re.match(r'^(.*?):(\d+):(.*)$', l) + if m: hits.append((m.group(1), int(m.group(2)), m.group(3))) + return hits + pat = re.compile((r'(? 2 << 20: continue + with open(p, 'rb') as fh: data = fh.read() + except OSError: continue + if b'\0' in data[:4096]: continue + for i, l in enumerate(data.decode('utf-8', 'replace').splitlines(), 1): + if pat.search(l): hits.append((f, i, l)) + return hits + + +class Places: + """the declaration that holds a line, in whichever graph holds its file""" + def __init__(self, repo): + self.cons = [c for c in (_con(db) for db in graph_dbs(repo)) if c] + self.cache = {} + + def graph_file(self, rel): + """(connection, the path that graph stores for this file) or (None, None). A graph built with --src stores paths + under it, so a repository path is matched by its longest suffix that some graph holds""" + if rel in self.cache: return self.cache[rel] + parts = rel.split('/'); found = (None, None) + for i in range(len(parts)): + cand = '/'.join(parts[i:]) + for c in self.cons: + try: + if c.execute("SELECT 1 FROM symbols WHERE file = ? LIMIT 1", (cand,)).fetchone(): found = (c, cand); break + except Exception: pass + if found[0]: break + self.cache[rel] = found + return found + + def of(self, rel, line): + """'in ' for the innermost declaration around the line, 'not indexed' for a file no graph holds, + '' for a line in an indexed file outside every declaration""" + c, f = self.graph_file(rel) + if not c: return 'not indexed' + try: + r = c.execute("""SELECT display, kind FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? + AND kind NOT IN ('module', 'library', 'written', 'file') + ORDER BY line DESC, end_line ASC LIMIT 1""", (f, line, line)).fetchone() + except Exception: r = None + return f"in {r[0]}" if r else '' + + +def block(repo, asked, scope=None, why='unresolved', rows=ROWS): + """the text lines to print for names the graph could not answer (`asked`), searched under `scope` when some file + lies there, else at the root (and said so). '' when there is nothing to search for""" + lines = [] + places = None; rowed = False + for a in dict.fromkeys(asked): + tries = needles(a) + if not tries: continue + hits, used = [], None + for n, w in tries: + hits = grep(repo, n, w); used = (n, w) + if hits: break + if why == 'string' and hits: + # a STRING in a source file is written quoted; the same word bare there is an identifier (a field, a local) + # and another question. A file no graph reads keeps every mention: a YAML value is written bare + places = places or Places(repo) + quoted = re.compile(r'["\'`]' + re.escape(used[0]) + r'["\'`]') + hits = [h for h in hits if quoted.search(h[2]) or not places.graph_file(h[0])[0]] + under = '' + if scope: + inside = [h for h in hits if scope in h[0]] + if inside: hits = inside; under = f", under {scope}" + elif hits: under = f"; none under {scope}, so these are from the whole repository" + n, w = used + also = f" (as '{n}')" if n != tries[0][0] else '' + # a name that reads as a file: the files whose path holds it come first, which is what the search by hand was for + paths = [] + if _FILE_LIKE.search(tries[0][0]) and ' ' not in tries[0][0]: + want = tries[0][0].lstrip('./') + paths = [f for f in files(repo) if f == want or f.endswith('/' + want) or want in f][:rows] + q = tries[0][0] if not tries[0][1] and a.strip()[:1] in '"\'`' else a # a quoted string, shown quoted once + head = {'unresolved': f"the graph has no declaration for '{q}'", 'quoted': f"no declaration is named '{q}'", + 'string': f"'{q}' is a string, so beside the literal sites above", + 'undeclared': f"no graph declares '{q}': the rows above match it by name only, and"}.get(why, f"'{q}'") + if paths: + lines.append(f"[text] files whose path holds '{tries[0][0]}' ({len(paths)}{'+' if len(paths) == rows else ''}):") + lines += [f" [text] {f}" for f in paths] + if not hits: + if not paths and why not in ('string', 'undeclared'): + lines.append(f"[text] {head}, and no line of the repository's files writes it either" + + (f" (nor '{n}')" if len(tries) > 1 else '') + ": absent from the text, not only from the graph.") + continue + places = places or Places(repo) + nfiles = len({h[0] for h in hits}) + rowed = True + lines.append(f"[text] {head}; the lines that write it{also} — text, not call edges " + f"({len(hits)} line(s) in {nfiles} file(s){under}):") + shown, per = [], {} + for f, ln, t in sorted(hits, key=lambda h: (h[0], h[1])): + if per.get(f, 0) >= PER_FILE: continue + per[f] = per.get(f, 0) + 1; shown.append((f, ln, t)) + # one row per file first, so twelve rows name twelve files rather than one file twelve times + seen = set(); firsts = [] + for h in shown: + if h[0] not in seen: seen.add(h[0]); firsts.append(h) + order = firsts + [h for h in shown if h not in firsts] + for f, ln, t in order[:rows]: + where = places.of(f, ln) + t = t.strip() + t = t if len(t) <= 110 else t[:107] + '…' + lines.append(f" [text] {f}:{ln}" + (f" {where}" if where else '') + f" | {t}") + rest = len(hits) - min(len(order), rows) + if rest > 0: + lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'") + if not lines: return '' + if rowed and why == 'unresolved': lines.append("next: these [text] rows are leads, not resolved edges — open the one that fits; for code the graph " + "does hold, ask again by the name of the declaration a row sits in") + return '\n'.join(lines) + '\n' + + +def emit(repo, asked, scope=None, why='unresolved', rows=ROWS): + """print the block (a verb alone) or hand what it would search for to the dispatcher (a verb that is one of several)""" + asked = [a for a in asked if a] + if not asked: return + if os.environ.get('AXIOMCODE_FANOUT') == '1': + sys.stderr.write(MARK + json.dumps({'asked': asked, 'scope': scope, 'why': why, 'rows': rows}) + '\n'); sys.stderr.flush() + return + b = block(repo, asked, scope, why, rows) + if b: sys.stdout.write('\n' + b); sys.stdout.flush() + + +def take(err): + """(stderr without the markers, [the marker objects])""" + keep, got = [], [] + for l in err.splitlines(keepends=True): + if l.startswith(MARK): + try: got.append(json.loads(l[len(MARK):])) + except ValueError: pass + else: keep.append(l) + return ''.join(keep), got diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 83049cf50..75938dbc4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -244,13 +244,16 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +# A GRAPH A QUERY FOUND CORRUPT (ax_fresh.quarantine leaves .axiomcode/out/corrupt) is rebuilt even though no file moved: +# the files still match the table, so without this the build said "up to date" over a graph that could not be read. +if [ -f "$OUT/corrupt" ]; then echo "rebuilding: $(head -1 "$OUT/corrupt")"; fi +if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 fi -if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then +if [ ! -f "$OUT/corrupt" ] && [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. @@ -418,6 +421,7 @@ publish_main(){ done commit_base rm -rf "$PREV" + rm -f "$OUT/corrupt" "$OUT/$LANG_ARG/graph.sqlite.corrupt"* "$LANGDIR"/*/out/graph.sqlite.corrupt* # a sound graph replaced what was quarantined MAIN_DONE=1; PUBLISHING="" # the languages still unsolved are named in $OUT/partial: the graph is not up to date while it is there, so the next # build (a refresh the next query starts, or an index) builds them rather than calling the file table current diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index b40b2b5af..81b1858d7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -33,8 +33,9 @@ import importlib.machinery, importlib.util, math, os, re, sys, collections HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) -import ax_edges +import ax_edges, ax_text from ax_contract import (subtokens, TESTY, SPLIT, CAMEL, require_scope, sole_scope, best_scope, budgeted, BOUND, + scope_spec, under_scope, scope_sql, dirs_with_counts, drop_ancestors, offer, task_text, task_terms, winnow, headline, is_synthetic, code_terms, stem) _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) @@ -152,7 +153,7 @@ def from_starts(g, ids, landed, scopes=()): declaration stays. """ if scopes: - ids = [x for x in ids if any(sc in (g.sym[x].get('file') or '') for sc in scopes)] + ids = [x for x in ids if any(under_scope(g.sym[x].get('file'), scope_spec(sc, g.repo)) for sc in scopes)] if len(ids) < 2: return ids reach = flow_words(g, ids, landed) hit = sorted((x for x in ids if reach[x]), key=lambda x: (-len(reach[x]), bool(g.sym[x].get('is_test')))) @@ -518,6 +519,7 @@ def unseen(g, text, scopes=()): NONSOURCE_ASK = re.compile(r'\b(sql|quer(y|ies)|statements?|mapper|mappings?|xml|ya?ml|propert(y|ies)|config(uration|ured)?|' r'settings?|templates?|views?|html|json|migrations?|schema|scripts?|toml|ini|env|bindings?)\b', re.I) BINDINGS_SHOWN = 6 +REMOTE_SHOWN = 8 # cross-process hops listed beside "where the work is" (#1293) _NS = {} @@ -917,7 +919,10 @@ def main(argv): if not terms: die("nothing to search for in that task description") # what the question names that no graph here holds is said FIRST (#1571), and a scope that exists on disk but holds # no indexed file is a text scope, not a typo (#1382). Only a scope the index does not know is checked on disk. - unknown_scopes = [x for x in scopes if not g.q("SELECT 1 FROM symbols WHERE file LIKE ? LIMIT 1", f'%{x}%')] + def holds(x): + cond, params = scope_sql(scope_spec(x, g.repo)) + return g.q(f"SELECT 1 FROM symbols WHERE {cond} LIMIT 1", *params) + unknown_scopes = [x for x in scopes if not holds(x)] notice, named_langs, text_dirs, langs, mine = unseen(g, body, unknown_scopes) main_lang = next(iter(langs)) fan = os.environ.get('AXIOMCODE_FANOUT') == '1' and len(langs) > 1 @@ -934,6 +939,19 @@ def main(argv): if notice: print() if text_dirs: scopes = [x for x in scopes if x.strip('/').lstrip('./') not in text_dirs] + # AN --in NO GRAPH HOLDS AND NO DIRECTORY IS was refused with a menu, once per language graph, and no answer: the + # agent then searched by hand. A typo is not a question about nothing; the task is answered at the root (or under + # the scopes that are real) and the line says what was dropped and what is close. A scope another language's graph + # holds is left alone: this graph refuses it and that one answers; the line is printed once (ax_langs.py) + if scopes and not scope_offered: + gone = [x for x in scopes if x in unknown_scopes and not ax_text.held(g.repo, x)] + if gone: + scopes = [x for x in scopes if x not in gone] + import difflib + near = difflib.get_close_matches(gone[0], [d for d, _ in dirs_with_counts(g).items()], n=3, cutoff=0.4) + print(f"{ax_text.SCOPE_GONE}{', '.join(repr(x) for x in gone)} in its path — answering " + + (f"under {', '.join(scopes)}" if scopes else "at the repository root") + " instead" + + (f" (close: {', '.join(near)})" if near else '') + "\n") # the terms the report itself marked as code take the budget first (see winnow) terms = winnow(g, terms, strong=code_terms(body)) scored = score_symbols(g, terms) @@ -944,7 +962,7 @@ def main(argv): # one that does not declare the name refuses, so the answer comes from the language that does. fixed = [x.strip().replace('\\', '/') for x in scopes] if not scope_offered else [] from_ids = {sel: [x for x in g.resolve(sel)[1] if x in g.sym] for sel in starts} - held = {sel: [x for x in ids if not fixed or any(sc in (g.sym[x].get('file') or '') for sc in fixed)] + held = {sel: [x for x in ids if not fixed or any(under_scope(g.sym[x].get('file'), scope_spec(sc, g.repo)) for sc in fixed)] for sel, ids in from_ids.items()} if starts and os.environ.get('AXIOMCODE_FANOUT') and not any(held.values()): print(f"nothing named {', '.join(repr(s) for s in starts)} in this graph" @@ -1006,7 +1024,8 @@ def main(argv): # a symbol is kept when it is under ANY of the scopes -- the union, because the caller naming two roots # is naming a change that spans them, and intersecting those two paths answers nothing (#1029) - in_scope = lambda f: any(sc in (f or '') for sc in scopes) + specs = [scope_spec(sc, g.repo) for sc in scopes] + in_scope = lambda f: any(under_scope(f, sp) for sp in specs) if scopes and not scope_offered: scored = {sid: v for sid, v in scored.items() if in_scope(g.sym.get(sid, {}).get('file'))} # what the question NAMES comes before what its words match: a declaration spelled out, a route quoted @@ -1169,6 +1188,35 @@ def main(argv): if b > a + SOURCE_LINES: print(f" {'':22} {'':5} | … +{b - a - SOURCE_LINES} more line(s)") if foot and budget > 0: print(foot + ", tests and benchmarks not listed") + # THE OTHER END OF A ROUTE IS NO CALL HOP AWAY, HOWEVER CLOSE IT IS (#1293). A client and the handler it reaches + # through an HTTP route, a topic or a gRPC service are joined by ext_remote_edge, which `impact` and `path` read and + # this did not, so the service on the other side never appeared. Its own tier, with the transport and destination, + # and never merged into the hops above: it crosses a process and may be asynchronous. + RESULT['remote'] = [] + if g.has('ext_remote_edge') and depth: + rows = [] + for r in g.q("SELECT c0, c1, c2, c3, c4 FROM ext_remote_edge"): + fr, to = r[0], r[1] + if not fr or not to or fr == to or fr not in g.sym or to not in g.sym: continue + d = min(depth.get(fr, 99), depth.get(to, 99)) + if d == 99: continue + near_is_sender = depth.get(fr, 99) <= depth.get(to, 99) + rows.append((d, g.disp(fr), g.disp(to), fr, to, r[2], r[3], r[4], near_is_sender)) + rows.sort(key=lambda x: (x[0], x[1], x[2], x[6])) + if scopes and not scope_offered: + rows = [x for x in rows if in_scope(g.sym[x[3]].get('file')) or in_scope(g.sym[x[4]].get('file'))] + if rows: + print(f"\nacross a process boundary ({len(rows)} remote hop(s) from what is above; not calls — joined by the " + "destination both ends name):") + for d, _a, _b, fr, to, tr, dest, conf, near_is_sender in rows[:REMOTE_SHOWN]: + far = to if near_is_sender else fr + print(f" [remote] {tr} {dest} ({conf}) {g.disp(fr)} → {g.disp(to)} " + f"{'handled in' if near_is_sender else 'sent from'} {g.loc(far)}") + if len(rows) > REMOTE_SHOWN: print(f" … +{len(rows) - REMOTE_SHOWN} more") + RESULT['remote'] = [{'from': g.disp(fr), 'from_at': g.loc(fr), 'to': g.disp(to), 'to_at': g.loc(to), + 'transport': tr, 'destination': dest, 'confidence': conf, 'hop': d} + for d, _a, _b, fr, to, tr, dest, conf, _n in rows] + # the text files bound to what was found (#1382): only when the question is about them (SQL, config, a template, a # file or directory the graph does not hold), because finding them walks the tree RESULT['text_bindings'] = [] @@ -1216,11 +1264,39 @@ def main(argv): return 0 +# A QUOTED STRING IN THE TASK is usually a message, a key or a value the reader saw somewhere ("nothing named X", +# `AXIOMCODE_NO_REFRESH`): the graph holds declarations, not what they print, so its words scored against names and the +# agent then searched for the string by hand. Where it is written is a text search, so it is one, labelled [text] and +# printed after the answer; a quoted name some graph declares is the graph's question and is left to it +QUOTED = re.compile(r'"([^"\n]{4,120})"|(? 1 else '.') + asked = [] + for m in QUOTED.finditer(task_text(task)): + s = next(x for x in m.groups() if x is not None).strip() + if len(s) < 4 or s.startswith('/') or s in asked: continue # a route: the graph's route seeds answer it + if ax_text.declared(repo, re.split(r'[.#:$]', re.sub(r'\(.*\)$', '', s))[-1]): continue + asked.append(s) + ax_text.emit(repo, asked[:3], why='quoted', rows=4) + + def cli(argv): """--json: the same answer as one document. The prose is produced by the same code path and carried in `prose`, so the machine shape is never LESS than the human one — a second traversal to build it would be a second implementation to keep in step.""" - if '--json' not in argv: sys.exit(main(argv)) + if '--json' not in argv: + code = main(argv) + if code in (0, 2): quoted_text(argv) + sys.exit(code) import contextlib, io, json buf = io.StringIO() with contextlib.redirect_stdout(buf): code = main(argv) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index 478b64f18..b117bc73d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -31,7 +31,7 @@ the caller, never a drawn edge. import os, re, sys, json, time, sqlite3, collections, subprocess HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import ax_contract +sys.path.insert(0, HERE); import ax_contract, ax_fresh TESTRE = re.compile(r'(^|/)(tests?|testing|__tests__|test-data|fixtures?)(/|$)|(^|/)test_|conftest\.py$|/src/test/|Tests?\.java$|\.(spec|test)\.[cm]?[tj]sx?$') METHOD_KINDS = ('function', 'method', 'constructor', 'module') TYPE_KINDS = ('class', 'interface', 'enum', 'type', 'namespace') @@ -51,13 +51,17 @@ def graph_dbs(repo, gdir): ld = os.path.join(repo, '.axiomcode', 'lang') for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): d = os.path.join(ld, l, 'out', 'graph.sqlite') - if os.path.exists(d): dbs.append(d) + if not os.path.exists(d): continue + why = ax_fresh.graph_corrupt(d) + # another language's corrupt graph is left out of the page, said, rather than ending the page in a traceback + if why: print(f"graph refresh: the {l} graph at {d} is corrupt ({why}) and is left out of this page; `axiomcode index` rebuilds it", file=sys.stderr) + else: dbs.append(d) return dbs def export(repo, page=None, how=''): repo = os.path.realpath(repo or '.') - gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode') + gdir = ax_contract.usable_graph(repo, os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode')) db = os.path.join(gdir, 'out', 'graph.sqlite') # the same door every other verb goes through: built on demand under the CLI, refused in words # that name the one command that helps under anything else. @@ -76,7 +80,7 @@ def export(repo, page=None, how=''): if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) skipped, tiers = None, collections.Counter() for db in graph_dbs(repo, gdir): - con = sqlite3.connect(db); con.row_factory = sqlite3.Row + con = sqlite3.connect(db); con.row_factory = sqlite3.Row; ax_contract.on_corrupt(repo, db) q = lambda sql: con.execute(sql).fetchall() has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8ded6586c..9e35904ae 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -58,11 +58,21 @@ def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges +sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) P = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(P) G, run_dl, die, BODILESS_KINDS = P.G, P.run_dl, P.die, P.BODILESS_KINDS + +def script_run(repo, rel): + """the command a script test (graph_sql.script_test_file) is run by: the one test-impact gives for the file — its + package's script, its own usage line, the interpreter — so impact and test-impact name the same command""" + global _TI + if '_TI' not in globals(): + spec = importlib.util.spec_from_loader('axtestimpact', importlib.machinery.SourceFileLoader('axtestimpact', os.path.join(HERE, 'axiomcode-test-impact'))) + _TI = importlib.util.module_from_spec(spec); spec.loader.exec_module(_TI) + return _TI.script_command(repo, rel) + # written per query (or supplied by the path tool's own export), so the cached export is NOT expected to hold them. Leaving # `nonsource` / `qual_name` / `edge` / `named` out of this set made the completeness check below unsatisfiable, so the 15 MB # export re-ran on EVERY query — the cache never hit once. @@ -384,9 +394,8 @@ class Impact: mfl = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) if mfl and ('/' in mfl.group(1) or '.' in mfl.group(1)) and kind in (None, 'field', 'type'): f, ln = self.g.repo_file(mfl.group(1))[0], int(mfl.group(2)) - files = sorted({x['file'] for x in self.g.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") - if x['file'] == f or (x['file'] or '').endswith('/' + f)}) - if f in files: files = [f] + files, scoped = self.g.line_files(f) # --in chooses among a partial path's files (#1666) + if scoped and len(files) == 1: s = f"{files[0]}:{ln}" d = self.g.decl_at_line(files[0], ln) if len(files) == 1 else None if d and d[0] == 'field' and kind in (None, 'field'): row = self.fields.get(d[1]['rowid']) or d[1] @@ -444,6 +453,9 @@ class Impact: if '#' in verb: hit = self.g.q("SELECT id FROM symbols WHERE (display = ? OR qualified_name = ?) AND method_id IS NOT NULL", verb, verb) if hit: return [('method', verb + (f" ({len(hit)} declarations)" if len(hit) > 1 else ''), [x['id'] for x in hit])] + # …and a private FIELD (`Vault.#secret`, `#secret`) the same way: rewritten, it asked for `Vault..secret` + rows = self.field_rows(verb) if kind in (None, 'field') and re.search(r'(^|\.)#[A-Za-z_$]', verb) else [] + if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] base = re.sub(r'\(.*\)$', '', s).replace('#', '.').strip('.') if not (self.field_rows(base) or self.types(base, soft=True) or self.methods(base, soft=True)): base = base.replace('$', '.') # Outer$Inner — unless $ is part of the name ($Gson$Types) m0 = re.match(r'^(.+)\.$', base) @@ -530,7 +542,10 @@ class Impact: except SystemExit: if soft: return [] print(buf.getvalue(), end=''); raise - last = re.sub(r'\(.*\)$', '', s).replace('#', '.').replace('$', '.').rstrip('.').split('.')[-1] + written = re.sub(r'\(.*\)$', '', s) + # a computed key (`Tagged.[Symbol.hasInstance]`) is one segment, dots and all + key = re.search(r'\.(\[[^\[\]]+\])$', written) + last = key.group(1) if key else written.replace('#', '.').replace('$', '.').rstrip('.').split('.')[-1] lam = bool(re.search(r'<\w+@L\d+>$', s)) # Owner.m.: the lambda at that line ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s or lam)] if not ids and not soft: die(f"{s} is not a method in the graph") @@ -552,8 +567,10 @@ class Impact: # the one declaration a file:line could not target, and its bare name answered for every const so named fl = re.fullmatch(r'(.+\.\w+):(\d+)', s) if fl: - p, n = fl.group(1), int(fl.group(2)) - return [f for f in self.fields.values() if f['line'] == n and (f['file'] == p or (f['file'] or '').endswith('/' + p))] + # a partial path names ONE file, as for a callable (#1599, #1666): matched against every file it ends, it took + # the field at that line in whichever of them had one, so `test.js:6` in 17 test.js answered for one of them + files, _ = self.g.line_files(self.g.repo_file(fl.group(1))[0]); n = int(fl.group(2)) + return [f for f in self.fields.values() if f['line'] == n and f['file'] == files[0]] if len(files) == 1 else [] parts = s.split('.'); name = parts[-1]; owner = '.'.join(parts[:-1]) rows = [f for f in self.fields.values() if f['name'] == name and (not owner or (f['owner'] or '') == owner or (f['owner'] or '').endswith('.' + owner) or (f['qualified_name'] or '').endswith('.' + s))] if not rows and '.' in s: # the dotted spelling, only after the written one @@ -730,7 +747,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '37' # 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '40' # 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -906,7 +923,9 @@ class Impact: for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER}})) # a call written inside a mock's stub or verification keeps its row and loses its tier: "stub" (ax_edges.stub_sites) stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() - W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + # a chained route link's edge sits on the line of its own verb and path, where `registration` labels it + link_line = ax_registration.route_site_lines(g.q, g.site_file) + W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) # records its edges on the statement's first line, while the handler is named on a later one @@ -1098,7 +1117,13 @@ class Impact: L = self.lines(sy['file']) if sy['line'] - 1 < len(L) and REGISTRAR.search(L[sy['line'] - 1]): tm.append(i) elif any(f == sy['file'] and a <= sy['line'] <= b for f, a, b in tables): tm.append(i) - fx = [i for i, s in g.sym.items() if s['is_test'] and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or s['name'] in FIXTURE_NAMES or any(FIXTURE_DECOR.match(d) for d in decs.get(i, ())))] + # …and a SCRIPT TEST, a test-tree file run as a program with no framework: its module is the test (graph_sql, + # the one rule the hooks' fast path reads too) + import graph_sql + scripts = graph_sql.script_tests([(i, s['kind'], s.get('file')) for i, s in g.sym.items() if s['is_test'] and s.get('method_id') and s.get('file')], + lambda f: '\n'.join(self.lines(f)), set(tm)) + tm += sorted(scripts) + fx = [i for i, s in g.sym.items() if s['is_test'] and i not in scripts and ((s.get('type_id') and not s.get('method_id')) or s['kind'] in ('constructor', 'module') or s['name'] in FIXTURE_NAMES or any(FIXTURE_DECOR.match(d) for d in decs.get(i, ())))] W('test_method', [(m,) for m in tm]); W('fixture', [(m,) for m in fx]) # ── which fixture runs before which test, when nothing calls it ──────────────────────────────────────── # pytest hands a fixture to a test BY THE NAME OF A PARAMETER, and the fixture that serves a whole directory @@ -1644,6 +1669,70 @@ def route_per_file(g, reached, hop0, files): res[f] = ' '.join(chain) return res +def scope_targets(g, rs): + """(the resolved targets, whether any was narrowed to --in) — the scope applied to the DECLARATIONS (#1584). + + `--in` bounded only the transitive layer, so `impact run --in plugins/x` answered for every `run` in the graph, all + of them outside the scope. A name with declarations under the scope now answers for those alone, as `path` always + did. A name with none there keeps all of them: the scope then asks who under it USES a declaration made elsewhere, + which is the question it always answered.""" + def file_of(x): + if isinstance(x, str): + r = g.sym.get(x) or g.all_sym.get(x) + return r.get('file') if r else None + try: return x['file'] + except (TypeError, KeyError, IndexError): return None + out, narrowed = [], False + for k, lab, pay in rs: + if isinstance(pay, list) and pay: + kept = [x for x in pay if file_of(x) and g.under_in(file_of(x))] + if kept and len(kept) < len(pay): + lab = re.sub(r' \(\+?\d+(?: declarations?(?: of that name)?)?\)$', '', lab) + f" [{len(kept)} of {len(pay)} declarations under {g.IN}]" + pay = kept + narrowed = narrowed or bool(kept) + out.append((k, lab, pay)) + return out, narrowed + + +# A TEST TREE THE INDEX NEVER READ. `index --src app` reads app/ only, and a project whose tests sit in a sibling tests/ +# then has no test in the graph at all: every answer said "tests: 0 of 0" or "0 of N", which reads as "nothing tests +# this" when the truth is "the tests were never looked at". So when no test is counted and the graph was built from a +# subtree, the test files of the graph's language lying outside it are counted and named, with the index that reads them. +OUTSIDE_EXT = {'python': ('.py',), 'java': ('.java', '.kt'), 'csharp': ('.cs',), 'typescript': ('.ts', '.tsx'), + 'javascript': ('.js', '.mjs', '.cjs', '.jsx')} +OUTSIDE_TEST = re.compile(r'(^|/)(tests?|__tests__|specs?)/|(^|/)test_[^/]*\.py$|_test\.py$|Tests?\.(java|kt|cs)$|IT\.java$' + r'|\.(test|spec)\.[cm]?[jt]sx?$') +OUTSIDE_SKIP = {'.git', '.axiomcode', 'node_modules', '.venv', 'venv', 'env', '.tox', 'build', 'dist', 'target', 'bin', 'obj', + '__pycache__', '.mypy_cache', '.pytest_cache', 'site-packages'} + + +def tests_outside_src(g, cap=20000): + """(the --src the graph was built from, [test files of its language outside it]) — ('', []) for a whole-repo graph""" + try: + run = dict(g.q("SELECT key, value FROM run")) + except Exception: + return '', [] + root = os.path.realpath(g.repo); src = os.path.realpath(run.get('source_dir') or root) + rel = os.path.relpath(src, root) + if rel == '.' or rel.startswith('..'): return '', [] + ext = OUTSIDE_EXT.get((run.get('language') or '').lower()) + if not ext: return rel.replace(os.sep, '/'), [] + out, seen = [], 0 + for d, dirs, fs in os.walk(root): + dirs[:] = sorted(x for x in dirs if x not in OUTSIDE_SKIP and not x.startswith('.') and os.path.join(d, x) != src) + for f in fs: + seen += 1 + if seen > cap: return rel.replace(os.sep, '/'), out + if not f.endswith(ext): continue + r = os.path.relpath(os.path.join(d, f), root).replace(os.sep, '/') + # a Python test tree also holds __init__.py, conftest.py and helpers: only the modules a runner collects count + if OUTSIDE_TEST.search(r) and (not f.endswith('.py') or re.fullmatch(r'test\w*\.py|\w+_test\.py', f)): out.append(r) + return rel.replace(os.sep, '/'), sorted(out) + + +STRING_TEXT = [] # (repo, target, --in, why): targets answered by text too (ax_text.py) + + def main(argv): args = list(argv); depth = 40; limit = 25; IN = None; as_json = '--json' in args; kind = None # --tests lists the tests grouped by rung and file; --tests-only prints that section and nothing else; @@ -1652,7 +1741,15 @@ def main(argv): tests_only = '--tests-only' in args; want_tests = '--tests' in args or tests_only; want_why = '--why' in args want_delete = '--delete' in args; want_warm = '--warm' in args; want_depends = '--depends' in args # the default view collapses a flood of [by name] rows; an explicit --limit or --page all asks for every row - list_all = '--limit' in args or getattr(sys.modules.get('ax_pages'), 'PAGE', None) == 'all' + # A LATER PAGE CONTINUES THE ROWS (ax_pages.paginate). Page 1 is the default view; page 2 on holds the rows that + # view left out. So whenever the pager captures the answer every row is printed, the ones the default view hides + # marked as hidden and its summary lines ("… +54 more", "+9 [by name]") marked as summaries: the pager builds + # page 1 from the one, pages the rest from the other, and counts what is left from the rows themselves. + # `--page all` prints every row, unmarked. + _pg = sys.modules.get('ax_pages'); _page = getattr(_pg, 'PAGE', 1) + every_row = _page == 'all'; later = isinstance(_page, int) and bool(getattr(_pg, 'CAPTURING', False)) + HIDE, SUMM = (getattr(_pg, 'HIDE', ''), getattr(_pg, 'SUMM', '')) if later else ('', '') + list_all = '--limit' in args or every_row args = [a for a in args if a not in ('--tests', '--tests-only', '--why', '--json', '--delete', '--warm', '--depends')] tests_in = None if '--tests-in' in args: @@ -1699,10 +1796,37 @@ def main(argv): if not args: die("usage: axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--json] [--kind k] [--delete]\n" " axiomcode impact --from-text [] [--top N] — an issue report in, the candidate declarations out\n" " axiomcode impact --warm [] — export the graph's facts now so the first query need not") + # AN --in NO GRAPH HOLDS narrowed the answer to nothing and said only "0 more callable(s) under it": a typo read as an + # answer. It is answered at the root and says so; a scope another language's graph holds is left alone (that graph + # answers under it); in a repository in several languages ax_langs.py prints the line once + if IN and not ax_text.held(repo, IN): + print(f"{ax_text.SCOPE_GONE}'{IN}' in its path — answering at the repository root instead", + file=sys.stderr if as_json else sys.stdout) + IN = None g = G(repo); g.IN = IN; I = Impact(g); prof('graph loaded, path facts exported') - targets = [] + I.kind_arg(kind) # a bad --kind is a usage error, not a name to search the text for + if IN: + cond, params = P.ax_contract.scope_sql(P.ax_contract.scope_spec(IN, g.repo)) + if not g.q(f"SELECT 1 FROM symbols WHERE {cond} LIMIT 1", *params): + # a scope this graph holds no file of: in a repository in several languages it is another language's + # (#1584), and the dispatcher leaves a graph that says this out when any other graph holds the scope + die(f"no indexed file has '{IN}' in its path.") + targets = []; scoped_in = False for a in args: - rs = I.resolve(a, kind) + try: rs = I.resolve(a, kind) + except SystemExit as e: + # nothing in the graph answers for it: say what a search by hand would find, labelled [text] (ax_text.py) + if e.code and not as_json: sys.stdout.flush(); ax_text.emit(repo, [a], IN) + raise + if IN: + rs, narrowed = scope_targets(g, rs) + scoped_in = scoped_in or narrowed + # a string is text before it is anything else: its literal sites below, and after them where it is WRITTEN + # and a name nothing declares, matched only where it is referenced by name (type_use), is answered by name + # alone: the lines that write it are what a search by hand was for. A library method is a resolved edge: not this + if not as_json and any(k == 'string' for k, _, _ in rs): STRING_TEXT.append((repo, a, IN, 'string')) + elif not as_json and any('not declared here' in lab and 'library method' not in lab for _, lab, _ in rs): + STRING_TEXT.append((repo, a, IN, 'undeclared')) kinds = {k for k, _, _ in rs} if kinds == {'field', 'method'} and not kind: # a fluent accessor, a record component, a Python @property: one concept written one way. Answering for @@ -1726,7 +1850,10 @@ def main(argv): print(f"note: {a} names {len(ids)} declarations in {len(files)} files ({where}) — this answer is for all of them; " f"target one by file:line (e.g. `impact {g.loc(ids[0])}`)", file=sys.stderr if as_json else sys.stdout) targets += rs - targets_files = [t for k, _, pay in targets for t in (pay if isinstance(pay, list) else []) if isinstance(t, str) and t in g.sym] + if IN and os.environ.get('AXIOMCODE_FANOUT') == '1': + # for ax_langs: a graph whose declarations of the name lie under --in outranks one whose lie elsewhere (#1584) + print(f"axiomcode-scope-declared: {int(scoped_in)}", file=sys.stderr) + targets_files =[t for k, _, pay in targets for t in (pay if isinstance(pay, list) else []) if isinstance(t, str) and t in g.sym] prof('targets resolved'); res = I.run(targets); prof('souffle done') LAB = res['_targets']; multi = len(LAB) > 1 contract_of = collections.defaultdict(set) @@ -1828,7 +1955,7 @@ def main(argv): for m, d, q_ in res['reach']: if 0 < int(d) <= depth and m in g.sym: reached[m] = min(int(d), reached.get(m, 10 ** 9)); reach_from[m].add(LAB[q_]) - if IN: reached = {m: d for m, d in reached.items() if IN in (g.sym[m]['file'] or '')} + if IN: reached = {m: d for m, d in reached.items() if g.under_in(g.sym[m]['file'])} parent = collections.defaultdict(list) for a, b, t, q_ in res['parent_up']: parent[a].append((b, t)) callers_of = collections.defaultdict(set) @@ -1847,6 +1974,12 @@ def main(argv): at_d = [x[2] for x in res['test_hit'] if x[0] == m and int(x[1]) == int(d)] _vias = sorted(v for v in at_d if v) tests[m] = (int(d), None if any(not v for v in at_d) else (_vias[0] if _vias else None)) + # A SCRIPT TEST (a module counted as a test) runs its own functions: `runCase` / `main` beside it is its body, not a + # fixture run before it, so its own route is the one to report even when it is a hop longer than the carrier's + for m, (d, via) in list(tests.items()): + if via and g.sym[m]['kind'] == 'module' and (g.sym.get(via) or {}).get('file') == g.sym[m]['file']: + own = [int(x[1]) for x in res['test_hit'] if x[0] == m and not x[2] and int(x[1]) <= depth] + if own: tests[m] = (min(own), None) # the same test, inherited: the subclass runs the base's test method, in its own file inh = {} for s_, m, d in {(x[0], x[1], int(x[2])) for x in res['inherited_test']}: @@ -1864,6 +1997,7 @@ def main(argv): return (f, int(ln), g.disp(c), c) if f and ln.isdigit() else (g.loc(c), 0, g.disp(c), c) sure_set = {x[0] for x in res['reach_sure']} # reached from the dependents that ARE an exact edge universe = sum(1 for _ in open(os.path.join(g.facts, 'impact', 'test_method.facts'))) + out_src = tests_outside_src(g) if not tests else ('', []) # an `alongside` row is not a place the change is observed from: it must stay out of the entry points, as it stays out # of the seeds — a reader is promised that an entry point reaches the change (#762) # …nor is a stub on a mock: it names the declaration and runs none of it @@ -1879,6 +2013,13 @@ def main(argv): u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} + # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by + script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} + _runs = {} + def script_cmd(m): + f = g.sym[m]['file'] + if f not in _runs: _runs[f] = script_run(g.repo, f) + return _runs[f] # A TEST THAT HOLDS A MOCK OF A TYPE ON ITS ROUTE never runs that type's methods (ax_edges.mocked_types): a web test # with `@MockBean OrderService` reaches the controller over HTTP and stops at the mock, so a route through # OrderService is not a route, whatever its rung. Read off the route the answer prints (the nearest one); the test @@ -1935,7 +2076,10 @@ def main(argv): 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'boundary_generated': 'sound', 'implicit_constructor': 'sound', # the candidate set was too large to enumerate, so what is here is a SAMPLE of it - 'fan_capped': 'by name'} + 'fan_capped': 'by name', + # a client across a process to the handler it reaches (#1293): joined by the destination both ends + # name, not by a call + 'remote': 'remote'} # The numbers that used to stand here (sound 0.645, one of a set 0.414, by key 0.396, fixture 0.233, by name # 0.133) were measured against a truth set that was SHORT: a pytest file which fails to COLLECT is emitted in # junit XML with classname="" and the module in `name`, the oracle read classname alone, and so every @@ -1973,7 +2117,10 @@ def main(argv): # is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow # the word that means one. CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, - 'by key': 6, 'decorator by name': 7, 'protocol': 8, 'fixture': 9, 'by name': 10} + 'remote': 6, 'by key': 7, 'decorator by name': 8, 'protocol': 9, 'fixture': 10, 'by name': 11} + # `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote + # hop's whole route and verb on both ends, where a key join is this file's own rules matching one literal. Not + # measured, like every rung below `one of a set`. # `registered` is inserted, and nothing above or below it is re-ordered: every rung the precisions were # measured for keeps the neighbours it had. It sits below `one of a set` because it is not a resolved # call, and above `by key` because the ENGINE recorded the hand-off, where a key join is this file's own @@ -2034,7 +2181,7 @@ def main(argv): # `decorator by name` is the rules' own hop from a decorated def to the decorator it names # (dl/impact.dl fw_edge), asserted exactly as `decorator` is; leaving it out printed "✗ not found" on # every answer whose test route passes through a project decorator - if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name') for x, t in parent.get(a, [])): bad += 1 + if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name', 'remote') for x, t in parent.get(a, [])): bad += 1 asserted = collections.defaultdict(set) # the callee each direct row claims an edge to for c, callee in {(x[0], x[1]) for x in res['direct_edge']}: asserted[c].add(callee) for c, role, why, cert, loc, n in D: @@ -2070,7 +2217,8 @@ def main(argv): # whatever the solver happened to emit — which is not the same between backends. 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], - 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], + 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])], + **({'script': True, 'run': script_cmd(m)} if m in script_ids else {})} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], 'framework_entries': [{'id': m, 'display': g.disp(m), 'signal': sig, 'at': g.loc(m)} for m, sig in fw_ent], 'framework_grep': fw_grep, 'stub_tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'at': g.loc(m), 'certainty': 'stubs it', @@ -2078,7 +2226,7 @@ def main(argv): 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], - 'test_universe': universe, 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) + 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) return 0 for k, lab, _ in targets: print(f"change: {lab} [{k}]") # --tests-only: every section is still COMPUTED (the tests are derived from them) but only the tests section, @@ -2226,6 +2374,31 @@ def main(argv): if dropped_alongside: print(f"({dropped_alongside} further `alongside` row(s) not shown — AXIOMCODE_ALONGSIDE_CAP={ALONG_CAP}; those kept share a field with the target or sit in its file)") print("(`alongside` = no call, no reference, no contract — only that a fix touching one of these often touches the other;\n" " it is never a seed, so the transitive layer below is unaffected by it)") + def byname_search(collapsed, seeds): + # THE SEARCH NAMES WHAT THE ROWS CALL, OR THE TARGET'S OWN NAME. It used to name the seeds' method names, and a + # type or a field has none a person can grep for: `impact User` said grep "", a Python class + # "", a C# type "", and a C# property the names of unrelated methods in its closure. + # So: the names each row's reason says it calls; else the target's name as written (the type, the field, the + # method); a compiler-made name (, __init__, constructor) is never one. + called = collections.Counter() + for r in collapsed: + for part in re.findall(r'\bcalls (?:the generated \w+ )?([^;(]*(?:\(\))?(?:, [^;,]+)*)', re.sub(r'calls a method of this name[^;]*', '', r[2])): + for x in re.findall(r'[A-Za-z_$][\w$]*', part): + if x not in ('the', 'generated', 'getter', 'setter', 'and', 'it', 'sites'): called[x] += 1 + made = re.compile(r'__\w+__|constructor|<[^>]*>') + nm = list(dict.fromkeys(x for x, _ in called.most_common() if not made.fullmatch(x)))[:4] + if nm: + return (f'grep -rn "\\.{nm[0]}(" {P.grep_roots(g)}' if len(nm) == 1 else + 'grep -rnw ' + ' '.join(f'-e "{n}"' for n in nm) + f' {P.grep_roots(g)}') + own = [] # "class User", "field BasketItem.UnitPrice", "Basket.Add", "f (at a.py:3)" + for k_, lab, _p in targets: + w = [x for x in re.findall(r'[A-Za-z_$][\w$]*', re.sub(r'<[^>]*>', '', lab.split(' (')[0])) if not made.fullmatch(x)] + if w: own.append((k_ if not lab.startswith('construction of') else 'type', w[-1])) + own = list(dict.fromkeys(own))[:4] + if not own: return '' + if len(own) == 1 and own[0][0] == 'method': + return f'grep -rn "\\.{own[0][1]}(" {P.grep_roots(g)}' + return 'grep -rnw ' + ' '.join(f'-e "{n}"' for _, n in own) + f' {P.grep_roots(g)}' for title, rows in groups: if not rows: continue # PRODUCTION BEFORE TESTS. A test that reaches the target is a CONSEQUENCE of the change, not a place that @@ -2262,19 +2435,26 @@ def main(argv): cap = max(3, limit // 5); per = collections.Counter(); shown = []; hidden = [] for r in body: f = (r[4] or '').rsplit(':', 1)[0] - if len(shown) < limit and per[f] < cap: per[f] += 1; shown.append(r) + if every_row or (len(shown) < limit and per[f] < cap): per[f] += 1; shown.append(r) else: hidden.append(r) - for c, role, why, cert, loc, n in shown: + def row(r, mark=''): + c, role, why, cert, loc, n = r why, oth = merge_reasons(why, direct_also.get((c, _grp(role))) or []) - print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + also_text(oth) + (f" [for {', '.join(sorted(direct_for[(c, _grp(role))]))}]" if multi else '')) + print(f"{mark} [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + also_text(oth) + (f" [for {', '.join(sorted(direct_for[(c, _grp(role))]))}]" if multi else '')) + for r in shown: row(r) if hidden: byf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in hidden) - print(f" … +{len(hidden)} more in {len(byf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in byf.most_common(6)) + (' …' if len(byf) > 6 else '') + " (--limit N for all of them)") + print(f"{SUMM} … +{len(hidden)} more in {len(byf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in byf.most_common(6)) + (' …' if len(byf) > 6 else '') + " (--page 2 for the next rows, --limit N for all of them)") + if later: + for r in hidden: row(r, HIDE) if collapsed: - nm = sorted({g.sym[t]['name'] for t in seeds if t in g.sym and g.sym[t].get('method_id') and g.sym[t].get('name')}) - srch = (f'grep -rn "\\.{nm[0]}(" {P.grep_roots(g)}' if len(nm) == 1 else P.grep_for(g, nm[0]) if nm else '') - print(f" +{len({c for c, *_ in collapsed})} [by name] (the same method name on an untyped receiver; may be another method)" + srch = byname_search(collapsed, seeds) + calls_ = any(re.search(r'\bcalls\b', r[2]) for r in collapsed) + print(f"{SUMM} +{len({c for c, *_ in collapsed})} [by name] " + ("(the same method name on an untyped receiver; may be another method)" if calls_ else + "(the name written where the engine could not bind it to this declaration; may be another one)") + (f" — {srch} lists them" if srch else '') + "; --page all or --limit N prints each row") + if later: + for r in collapsed: row(r, HIDE) # a test callable that only STUBS it on a mock names it and runs none of it: said apart, never as "reach it" tstub = [r for r in tst if r[3] == 'stubs it' or r[2].startswith('stubs a method of this name')] tst = [r for r in tst if r not in tstub] @@ -2362,6 +2542,11 @@ def main(argv): weak_tests = [m for m in tests if m not in sure_set] sys.stdout = _stdout print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before them: constructor, static initializer, @Before*/setUp)" if tests else '')) + if out_src[1]: + print(f" NOT SEEN: the graph was indexed with --src {out_src[0]}, and {len(out_src[1])} test file(s) lie outside it (" + + ', '.join(out_src[1][:3]) + (f" … +{len(out_src[1]) - 3}" if len(out_src[1]) > 3 else '') + + "), so no test there is counted: 0 here is not 'nothing tests this'. Index without --src (or with one that" + " holds the tests) to count them") # THE TESTS LINE AGREES WITH THE ROWS ABOVE IT. A callable in test code that this answer LISTS as reaching the change # (a [test] entry point, a test row) but that the count does not credit -- a runner entry this tool does not # collect (a custom test attribute, a script's main), a remote hop the test walk does not take -- made "tests: 0" @@ -2371,6 +2556,9 @@ def main(argv): test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert not in ('alongside', 'stubs it') and not why.startswith('stubs a method of this name')}) - set(tests) - set(stubbed), key=lambda m: (g.disp(m), g.loc(m))) + # a callable in a file whose test is counted runs when that file does (`main` under a script's main guard, the + # describe block around a counted `it`): the count already credits it + test_rows = [m for m in test_rows if g.sym[m]['file'] not in tfiles] if test_rows: print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " @@ -2440,13 +2628,16 @@ def main(argv): # when the file's tests share it, a count when they do not (each one's own is under --why) fxs = {g.disp(tests[m][1]) for m in ms if tests[m][1]} via = (f" via {next(iter(fxs))}" if len(fxs) == 1 else f" via {len(fxs)} fixtures" if fxs else '') + # a script test is a whole file run as a program: the command it runs by, never a framework's + sc = next((m for m in ms if m in script_ids), None) + head = f"{f} (script: {script_cmd(sc) or 'no command found: run the file as its project does'})" if sc else f"{f} ({len(ms)}){via}" if want_why: - print(f" {f} ({len(ms)}){via}:") + print(f" {head}:") for m in shown: r = route(m); print(f" {tname(m)} {tests[m][0]} hop(s)" + (f" {r}" if r else '')) if len(ms) > limit: print(f" … +{len(ms) - limit} more (--limit N)") else: - print(f" {f} ({len(ms)}){via}: " + ', '.join(tname(m) for m in shown) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) + print(f" {head}: " + ', '.join(tname(m) for m in shown) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) if not want_why: print(" --why adds each test's route (with --tests-in to keep it short)") if want_tests and stubbed: sfiles = collections.defaultdict(list) @@ -2478,7 +2669,7 @@ def main(argv): print(f"\ndepends on (what it calls, so what may have to change with it): {len(seen_d)} callable(s)") if not seen_d and not libs: print(" nothing resolved" + (f" — but its body has {unres} unresolved call(s), so what it depends on is unknown, not nothing" if unres else "")) - for m, (cert, f, l) in sorted(seen_d.items(), key=lambda x: (CERT_RANK.get(x[1][0], 9), g.sym[x[0]]['display']))[:limit]: + for m, (cert, f, l) in sorted(seen_d.items(), key=lambda x: (CERT_RANK.get(x[1][0], 10), g.sym[x[0]]['display']))[:limit]: print(f" [{cert}] {g.disp(m)} {g.loc(m)}") if len(seen_d) > limit: print(f" … +{len(seen_d) - limit} (--limit N)") if libs: print(" into libraries (not traversed): " + ', '.join(str(x['lab']).split('(')[0] for x in libs[:6]) + (f" … +{len(libs) - 6}" if len(libs) > 6 else '')) @@ -2760,6 +2951,18 @@ def main(argv): nt = len(tests) + len(test_rows) tpart = (f"; {nt} test(s) reach it — run them after the edit (`test-impact`)" if nt else '') if places: places = list(dict.fromkeys(places + [x for x in ext_locs if x not in here])) + # WHY NOTHING CALLS IT, every reason, from the one reader the hooks' `← ?` label and path's empty-upstream note read + # (graph_sql.no_caller_reasons): the hook said `? framework (@cache.memoize)` where this answer counted callers by + # name, and this answer named a library base where the hook named the annotation. Said where no production caller + # was found, before the verdict that depends on it. + if lone and not places: + _am = [i for i in asked if (g.sym.get(i) or {}).get('method_id')][:3] + try: _why = graph_sql.no_caller_reasons(g.q, _am) if _am else {} + except Exception: _why = {} + for i in _am: + if any(k not in ('entry', 'test') for k, _l, _a in _why.get(i) or ()): # an entry point alone is said above + print(f"why nothing in the graph calls {g.disp(i)}: " + '; '.join( + graph_sql.no_caller_phrase(k, l) + (f" ({at})" if at else '') for k, l, at in _why[i])) if not places and ext_locs: print(f"next: nothing resolved in the source depends on it, but {len(ext_locs)} site(s) outside the source {'define' if all(x[3].startswith('defines') for x in ext_strong) else 'name'} it and " f"break at RUN time on a rename, a move or a delete — read {', '.join(here) or 'the declaration'}, then " @@ -2777,7 +2980,17 @@ def main(argv): print(f"next: nothing in this graph calls it, but it is not local — it is {'an' if ep_why[0][:1] in 'aeiou' else 'a'} {'/'.join(ep_why[:3])} entry point, so its " f"callers are outside the graph (the framework, a request, a client, a runner, a scheduler): read {', '.join(here) or 'the declaration'}; a body change " f"is safe there, a change to its name, route, parameters or result breaks those callers{tpart}{lead}") - elif not places and unmod and (unmod_own or not maybe): + # THE ORDER IS graph_sql.NO_CALLER_KINDS, the order the hooks' `← ?` label and path's note read: a decoration on it, + # then the call sites that write its name, then what says only that a framework MAY enter its TYPE (a decoration on + # the type, a library base whose methods the graph does not hold) + elif not places and unmod and unmod_own: + print(f"next: {nobody}, but NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how; " + f"before treating the change as local run: {unmod_grep}{' — then read ' + ', '.join(here) if here else ''}{tpart}{lead}") + elif not places and weak_n: + print(f"next: no resolved caller, but {weak_n} caller(s) above reach it by name or text, which the graph could not confirm — " + f"check {', '.join(weak_locs[:4]) or 'them'}{' …' if len(weak_locs) > 4 else ''} before treating the change as contained, " + f"then read {', '.join(here) or 'the declaration'}{tpart}") + elif not places and unmod and not maybe: print(f"next: {nobody}, but NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how; " f"before treating the change as local run: {unmod_grep}{' — then read ' + ', '.join(here) if here else ''}{tpart}{lead}") elif not places and maybe: @@ -2785,10 +2998,6 @@ def main(argv): f"it is declared on a subtype of {', '.join(maybe[:3])}, which the graph does not contain: if it overrides one of " f"its methods (for a type: wherever one of its methods does), that library calls it with no call site here, so a " f"change to that name or signature is not local{'; to check: ' + unmod_grep if unmod_grep else ''}{lead}") - elif not places and weak_n: - print(f"next: no resolved caller, but {weak_n} caller(s) above reach it by name or text, which the graph could not confirm — " - f"check {', '.join(weak_locs[:4]) or 'them'}{' …' if len(weak_locs) > 4 else ''} before treating the change as contained, " - f"then read {', '.join(here) or 'the declaration'}{tpart}") elif not places and nt: print(f"next: no production code depends on it, but {nt} test(s) reach it — read {', '.join(here) or 'the declaration'} " f"and edit there, then run those tests (`test-impact` after the edit){lead}") @@ -2806,4 +3015,6 @@ def main(argv): if __name__ == '__main__': if not sys.argv[1:] or sys.argv[1] in ('-h', '--help'): print(__doc__); sys.exit(0) import ax_pages; ax_pages.install('impact') # --page / --budget, and the answer printed in pages (#1198) - sys.exit(main(sys.argv[1:])) + code = main(sys.argv[1:]) + for repo, a, scope, why in STRING_TEXT: sys.stdout.flush(); ax_text.emit(repo, [a], scope, why=why) + sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 55a16d40a..962c0b4c4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -20,7 +20,8 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections csv.field_size_limit(10**9) -INDEX_VERSION = '4' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function); the query +INDEX_VERSION = '5' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; + # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -123,7 +124,10 @@ A = { # each made the function or class it names look declared as a second kind, so `impact C` refused as ambiguous decls=[dict(file='all-javascript-variables.csv', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), kind=lambda r: 'const' if r.get('bindingRegime', '').startswith('CONST') else 'variable', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='endLine'), - dict(file='all-javascript-fields.csv', kind=lambda r: 'field', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='startLine')], + # a field's owner is its class by hash (`this.x = …` in a constructor, a class field, `F.prototype.x = …`), so it + # displays as `Store.items`, not `items`; a computed key (`[Symbol.iterator] = …`) is named by its key expression + dict(file='all-javascript-fields.csv', id='jsFieldUniqueHash', kind=lambda r: 'field', name='name', owner=None, ownerType='ownerTypeLinkHash', computed='computedNameExpressionLinkHash', + fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='startLine')], # `const foo = () => …`: the variable's initializerExpressionLinkHash is the FUNCTION_EXPRESSION row whose # introducesDeclarationLinkHash is the method — two hops, where TypeScript has boundFunctionLinkHash in one boundNames=dict(file='all-javascript-variables.csv', name='name', only=lambda r: r.get('initializerKind') == 'FUNCTION', @@ -132,6 +136,12 @@ A = { # is the value of, the way the language's own name inference does it, and is owned by the object's variable memberNames=dict(file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', vars='all-javascript-variables.csv'), + # `static [Symbol.hasInstance](x) {…}`: the parser leaves the name empty and links the key expression; the member is + # named `[Symbol.hasInstance]`, as written, instead of displaying as `Tagged.` with nothing to ask for + computedNames=dict(file='all-javascript-methods.csv', id='jsMethodUniqueHash', key='computedNameExpressionLinkHash', + expr='all-javascript-expressions.csv', exprId='jsExpressionUniqueHash', text='text'), + # `const Foo = class {…}`: the parser's type is ``; it takes the variable, field or property it is bound to + classNames=dict(file='all-javascript-types.csv', id='jsTypeUniqueHash', fields='all-javascript-fields.csv'), skipped='skipped-javascript-files.csv'), 'csharp': dict( modules=dict(file='all-csharp-modules.csv', id='csModuleUniqueHash', filePath='filePath'), @@ -212,41 +222,7 @@ CREATE TABLE paths(raw TEXT PRIMARY KEY, rel TEXT); raw_paths = {r[0] for t in ('call_sites', 'methods', 'types') for r in c.execute(f"SELECT DISTINCT file_path FROM {t} WHERE file_path IS NOT NULL")} c.executemany("INSERT OR IGNORE INTO paths VALUES (?,?)", ((p, rel(p)) for p in raw_paths)) -# ── types: display names with nesting recovered by line containment ─────────────────────────────── -types = {r['id']: dict(r) for r in c.execute("SELECT id, name, qualified_name, category, file_path, start_line, end_line, provenance FROM types")} -byfile = {} -for t in types.values(): - if t['file_path'] and t['start_line'] is not None: byfile.setdefault(t['file_path'], []).append(t) -outer_of = {} -for fp, ts in byfile.items(): - ts.sort(key=lambda t: (t['start_line'], -(t['end_line'] or 0))) - for i, t in enumerate(ts): - best = None - for o in ts[:i]: # the narrowest earlier type whose range contains t - if o['start_line'] < t['start_line'] and (o['end_line'] or 0) >= (t['end_line'] or 0) and (best is None or (o['end_line'] - o['start_line']) < (best['end_line'] - best['start_line'])): - best = o - if best: outer_of[t['id']] = best['id'] -c.executemany("INSERT INTO nesting VALUES (?,?)", outer_of.items()) -def tname(t): - n = t['name'] or '' - m = re.match(r'^(.*)\$anon:(.+)$', n) # Java: Outer$anon:Iface - return f"" if m else n -disp = {} -def tdisplay(tid, seen=()): - if tid in disp: return disp[tid] - t = types.get(tid) - if not t: return '' - o = outer_of.get(tid) - d = (tdisplay(o, seen + (tid,)) + '.' if o and o not in seen else '') + tname(t) - disp[tid] = d; return d -sym = [] -for t in types.values(): - if t['provenance'] != 'client': continue - d = tdisplay(t['id']); fp = rel(t['file_path']) - sym.append((t['id'], tname(t), d, type_kind(t['category'] or ''), t['qualified_name'], None, fp, t['start_line'], t['end_line'], - tdisplay(outer_of[t['id']]) if t['id'] in outer_of else None, 1 if fp and TESTRE.search(fp) else 0, None, t['id'])) - -# ── methods ─────────────────────────────────────────────────────────────────────────────────────── +# ── names a function or class takes from what it is bound to ────────────────────────────────────── bound = {} if A['boundNames']: b = A['boundNames'] @@ -266,7 +242,7 @@ if A['boundNames']: # PROPERTY_KEY sibling at the same childIndex). The owner is the receiver as written (`helpers`, `Foo` for # `Foo.prototype`), none for `exports` / `module.exports` / `this`; for a literal, the variable it initialises or the # key chain of the literal it is nested in. A named function expression keeps its own name: only placeholders move. -bound_owner = {} +bound_owner = {}; cls_named = {} if A.get('memberNames'): mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) for r in rows(mn['file']): @@ -285,11 +261,9 @@ if A.get('memberNames'): if not k or k.get('isComputedName') == 'true' or not k.get('name'): return None o = literal_owner(p, depth + 1) return f"{o}.{k['name']}" if o else k['name'] - for r in ex.values(): - m_ = r.get(mn['method']) - if not m_ or m_ in bound or r.get('expressionKind') != 'FUNCTION_EXPRESSION': continue + def named_by(r): p = ex.get(r.get('parentExpressionLinkHash', '')) - if not p: continue + if not p: return None, None role = r.get('edgeRole'); name = owner = None if role == 'ASSIGNMENT_VALUE' and p.get('expressionKind') == 'ASSIGNMENT' and p.get('operatorString') == '=': t = key_of(p[mn['id']], 'ASSIGNMENT_TARGET', None) @@ -301,9 +275,80 @@ if A.get('memberNames'): k = key_of(p[mn['id']], 'PROPERTY_KEY', r.get('childIndex')) if k and k.get('isComputedName') != 'true' and k.get('name'): name = k['name']; owner = literal_owner(p) + return name, owner + # `const Foo = class {…}`, `static Inner = class {…}`, `ns.Bar = class {…}`: an anonymous class expression takes + # the name it is bound to, as a function expression does, so its members display as `Outer.Inner.ping`, not + # `Outer..ping`. The IR does not link the expression to its type; both start at one position. + cls = A.get('classNames'); ANON_CLASS = re.compile(r'^class(\s|\\n)*(extends\b|\{)') + field_init = {r['initializerExpressionLinkHash']: r['name'] for r in rows(cls['fields']) if r.get('initializerExpressionLinkHash') and r.get('name')} if cls else {} + for r in ex.values(): + if r.get('expressionKind') != 'FUNCTION_EXPRESSION': continue + m_ = r.get(mn['method']) + if not m_: + if cls and ANON_CLASS.match(r.get('text') or ''): + i = r[mn['id']]; name = lit_var.get(i) or field_init.get(i); owner = None + if not name: name, owner = named_by(r) + if name: cls_named[(r.get('ownerModuleLinkHash'), r.get('startLine'), r.get('startColumn'))] = (name, owner) + continue + if m_ in bound: continue + name, owner = named_by(r) if name: bound[m_] = name if owner: bound_owner[m_] = owner +# ── types: display names with nesting recovered by line containment ─────────────────────────────── +types = {r['id']: dict(r) for r in c.execute("SELECT id, name, qualified_name, category, file_path, start_line, end_line, provenance FROM types")} +byfile = {} +for t in types.values(): + if t['file_path'] and t['start_line'] is not None: byfile.setdefault(t['file_path'], []).append(t) +outer_of = {} +for fp, ts in byfile.items(): + ts.sort(key=lambda t: (t['start_line'], -(t['end_line'] or 0))) + for i, t in enumerate(ts): + best = None + for o in ts[:i]: # the narrowest earlier type whose range contains t + if o['start_line'] < t['start_line'] and (o['end_line'] or 0) >= (t['end_line'] or 0) and (best is None or (o['end_line'] - o['start_line']) < (best['end_line'] - best['start_line'])): + best = o + if best: outer_of[t['id']] = best['id'] +c.executemany("INSERT INTO nesting VALUES (?,?)", outer_of.items()) +cls_bound = {} # type id -> (name, owner as written) of a bound anonymous class +if cls_named: + for r in rows(A['classNames']['file']): + v = cls_named.get((r.get('ownerModuleLinkHash'), r.get('startLine'), r.get('startColumn'))) + if v and r.get('typeCategory') == 'ANONYMOUS_CLASS' and r.get(A['classNames']['id']) in types: cls_bound[r[A['classNames']['id']]] = v +def tname(t): + if t['id'] in cls_bound: return cls_bound[t['id']][0] + n = t['name'] or '' + m = re.match(r'^(.*)\$anon:(.+)$', n) # Java: Outer$anon:Iface + return f"" if m else n +disp = {} +def tdisplay(tid, seen=()): + if tid in disp: return disp[tid] + t = types.get(tid) + if not t: return '' + o = outer_of.get(tid); ow = cls_bound.get(tid, (None, None))[1] + d = (tdisplay(o, seen + (tid,)) + '.' if o and o not in seen else ow + '.' if ow else '') + tname(t) + disp[tid] = d; return d +sym = [] +bound_at = set() # (file, line, name) of a variable or field that IS a function or class: its row is not a second symbol +for t in types.values(): + if t['provenance'] != 'client': continue + d = tdisplay(t['id']); fp = rel(t['file_path']) + if t['id'] in cls_bound: bound_at.add((fp, t['start_line'], tname(t))) + sym.append((t['id'], tname(t), d, type_kind(t['category'] or ''), t['qualified_name'], None, fp, t['start_line'], t['end_line'], + tdisplay(outer_of[t['id']]) if t['id'] in outer_of else cls_bound.get(t['id'], (None, None))[1], 1 if fp and TESTRE.search(fp) else 0, None, t['id'])) + +# ── methods ─────────────────────────────────────────────────────────────────────────────────────── +# a member whose key is an expression (`[Symbol.iterator]() {…}`, `[KEY] = 1`) has no name in the IR, only a link to the +# key expression: it is named `[]`. Only the linked expressions are kept, so the common case reads no extra file. +ckey = {}; cmeth = {} +cn = A.get('computedNames') +if cn: + for r in rows(cn['file']): + if not r.get('name') and r.get(cn['key']) and r.get(cn['id']): cmeth[r[cn['id']]] = r[cn['key']] + want = set(cmeth.values()) | {r[d['computed']] for d in A['decls'] if d.get('computed') for r in rows(d['file']) if not r.get(d['name']) and r.get(d['computed'])} + if want: + for r in rows(cn['expr']): + if r.get(cn['exprId']) in want and r.get(cn['text']): ckey[r[cn['exprId']]] = '[' + ' '.join(r[cn['text']].split())[:80] + ']' # Java: a method declared in an enum CONSTANT's body (`Initial { boolean process(...) {…} }`) is registered per constant with the # same qualified_name and the enum as owner — 23 `HtmlTreeBuilderState.process` rows in the JVM parser. The constant's span names it. econst = collections.defaultdict(list) @@ -316,9 +361,8 @@ def enum_constant_of(fp, ln): for a, b, n in econst.get(fp, ()): if a <= ln <= b and b > a: return n return None -bound_at = set() # (file, line, name) of a variable that IS a function: the variable row is not a second symbol for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type_id, owner_qualified_name, file_path, start_line, end_line FROM methods WHERE provenance='client'"): - name = m['name'] or '' + name = m['name'] or ckey.get(cmeth.get(m['id'], ''), '') if name.startswith('<') and m['id'] in bound: name = bound[m['id']]; bound_at.add((rel(m['file_path']), m['start_line'], name)) od = tdisplay(m['owner_type_id']) if m['owner_type_id'] else bound_owner.get(m['id']) if m['kind'] == 'ENUM_CONSTANT_METHOD' and od: @@ -373,9 +417,11 @@ def file_of(r, d): for d in A['decls']: for r in rows(d['file']): if d.get('only') and not d['only'](r): continue - name = r.get(d['name'], ''); + name = r.get(d['name'], '') or (ckey.get(r.get(d['computed'], ''), '') if d.get('computed') else '') if not name: continue - fp = file_of(r, d); od = owner_display(r.get(d['owner'], '')) if d.get('owner') else None + fp = file_of(r, d) + # the owner by qualified name (Java, TypeScript) or by the owning type's id (JavaScript) + od = owner_display(r.get(d['owner'], '')) if d.get('owner') else (tdisplay(r.get(d['ownerType'], '')) or None) if d.get('ownerType') else None # the owner as a link to the declaring type's row (C#): its display and qualified name come from that row, so a # nested type's member is `Outer.Inner.Member` and two types' same-named members stay two symbols ot = types.get(r.get(d['ownerVia'], '')) if d.get('ownerVia') else None diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 585a7fa03..c57d48bed 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -33,7 +33,7 @@ look at, never as a path. import contextlib, io, json, os, re, sys, sqlite3, subprocess, tempfile, shutil, collections, glob, time HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract +sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract, ax_text # The certainty table used to live here with SEVEN entries and a default of 5 for everything else. The # engine emits ELEVEN tiers, and the five it did not list all fell BELOW `defines` — which is not a call # at all — so on a JavaScript graph, where 8,916 of 25,048 traversable edges are `callback_registered`, @@ -42,6 +42,7 @@ sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract TIER_RANK = ax_edges.TIER_RANK def die(msg, code=2): print(msg); sys.exit(code) +UNRESOLVED = [] # endpoints G.resolve found nothing by, in the order refused # ── --json: the same answer, for a program ──────────────────────────────────────────────────────── # Filled as the human output is produced, from the SAME chain objects, so the two cannot disagree: @@ -68,6 +69,14 @@ BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIG # 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 = {''} +# a reference the parser binds to a VALUE (refs.entity_kind, as each front end spells it): the name there is a variable, +# a parameter, a member or a function, never a type, so it is not evidence of a type the code uses without declaring +VALUE_BINDINGS = {'VARIABLE', 'LOCAL_VARIABLE', 'GLOBAL_VARIABLE', 'FREE_VARIABLE', 'NONLOCAL_VARIABLE', 'PARAMETER', + 'LAMBDA_PARAMETER', 'PATTERN_BINDING', 'PATTERN_BINDING_VARIABLE', 'COMPREHENSION_VARIABLE', 'EXCEPT_VARIABLE', + 'QUERY_RANGE_VARIABLE', 'FIELD', 'PROPERTY', 'EVENT', 'METHOD', 'TYPE_PARAMETER', 'THIS', 'SUPER'} +# a reference to the name as a MEMBER of something (refs.kind): `a.b.Name` names a property, not a type in scope +MEMBER_REFS = {'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS', 'FIELD_ACCESS', 'MEMBER_ACCESS'} +VALUE_KINDS_SHOWN ={k.lower().replace('_', ' ') for k in VALUE_BINDINGS} # ONE SPELLING FOR EVERY LANGUAGE. Each front end writes a qualified name its own way: Java and C# `pkg.Owner.m`, # Python `pkg.module.f`, TypeScript `src/util#square`, JavaScript `src/util.square`. canon() maps all of them onto the @@ -108,6 +117,8 @@ class G: self.repo = os.path.realpath(repo or '.') os.environ.setdefault('AXIOMCODE_REPO', self.repo) # which repository's recorded engine dl_program looks in gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(self.repo, '.axiomcode') + # a CORRUPT graph is moved aside and rebuilt, and the last good graph answers meanwhile (ax_contract.usable_graph) + gdir = ax_contract.usable_graph(self.repo, gdir) self.db = os.path.join(gdir, 'out', 'graph.sqlite') # ONE door for every verb. impact, changed, context and test-impact all load this file as a # module and construct this class, so a repository with no graph is handled here, once, for @@ -117,6 +128,7 @@ class G: if not os.path.exists(self.db) and not ax_contract.ensure_graph(self.repo, self.db): die(ax_contract.no_graph(self.repo, self.db)) self.connect() + ax_contract.on_corrupt(self.repo, self.db) if not self.has('symbols'): # a graph that was built but never indexed: the index is this tool's own, and adding it # is seconds, so asking the caller to run one more command buys nothing. @@ -258,6 +270,11 @@ class G: # ── endpoints: exact, or a short list of what is close ─────────────────────────────────────────────────── IN = None + def under_in(self, f): + """is file `f` under --in: by prefix when the scope names a path of the repository, else anywhere (#1584)""" + if not self.IN: return True + if getattr(self, '_in_spec', (None,))[0] != self.IN: self._in_spec = (self.IN, ax_contract.scope_spec(self.IN, self.repo)) + return ax_contract.under_scope(f, self._in_spec[1]) def resolve(self, sel, fragment=False): """-> (label, [method symbol ids]) or dies with the near misses. Same rules for every language. @@ -267,7 +284,7 @@ class G: """ label, ids = self._resolve(sel, fragment) if self.IN and not all(self.sym[i]['kind'] in ('library', 'written') for i in ids): # a library endpoint has no file; --in applies to the other end - kept = [i for i in ids if self.IN in (self.sym[i]['file'] or '')] + kept = [i for i in ids if self.under_in(self.sym[i]['file'])] if not kept: die(f"{label}: none of its {len(ids)} declaration(s) is under *{self.IN}*") if len(kept) < len(ids): label += f" [{len(kept)} under *{self.IN}*]" ids = kept @@ -302,6 +319,23 @@ class G: if p == root or p.startswith(root.rstrip('/') + '/'): return os.path.relpath(p, root).replace(os.sep, '/'), False return f.lstrip('/'), True + def file_lines(self, f): + """how many lines the indexed file `f` has on disk, or None when it cannot be read there""" + try: + with open(os.path.join(self.repo, f), 'rb') as h: return len(h.read().splitlines()) + except OSError: return None + + def line_files(self, f): + """-> (the indexed files a file:line's path `f` names, the --in note when the scope chose among them). The file + written in full, else every file `f` is a suffix of. --IN CHOOSES AMONG THEM (#1666): it was applied only after + the file was settled, so `index.js:2 --in packages/a` was refused with every index.js listed. Under the scope + one file is the answer; two or more are the refusal's list; none leaves the list whole, and says so.""" + files = sorted({x['file'] for x in self.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") + if x['file'] == f or (x['file'] or '').endswith('/' + f)}) + if f in files: return [f], '' + if len(files) < 2 or not self.IN: return files, '' + kept = [x for x in files if self.under_in(x)] + return (kept, f" under *{self.IN}*") if kept else (files, f", none of them under *{self.IN}*") def _resolve(self, sel, fragment=False): s = sel.strip().strip('`"\'') # file:line, or file:line:col as a stack trace prints it — the column never changes which callable spans the line @@ -312,17 +346,22 @@ class G: # lib/ took the smallest span across BOTH files, so the answer was for whichever function at that line was # shorter, labelled with what was typed. The path is settled first: the file written in full, else the one # file it is a suffix of; two or more are listed, each with what spans the line there. - files = sorted({x['file'] for x in self.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") - if x['file'] == f or (x['file'] or '').endswith('/' + f)}) - if f in files: files = [f] + files, scoped = self.line_files(f) if len(files) > 1: rows = [] for fx in files[:12]: at = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY end_line - line LIMIT 1", fx, ln, ln) rows.append(f" {fx}:{ln} " + (self.disp(at[0]['id']) if at else '(top-level code)')) if len(files) > 12: rows.append(f" … +{len(files) - 12} more") - die(f"'{m.group(1)}' matches {len(files)} indexed files — give enough of the path to name one:\n" + '\n'.join(rows)) + die(f"'{m.group(1)}' matches {len(files)} indexed files{scoped} — give enough of the path to name one:\n" + '\n'.join(rows)) if files: f = files[0] + if scoped: s = f"{f}:{ln}" # the scope chose the file: the label names it + # A LINE OUTSIDE THE FILE IS REFUSED (#1601). Line 0, or a line past the end — a number copied before the file + # was edited, or from an old stack trace — spans no callable, so it fell through to the module node and was + # answered as the file's top-level code, as if that line existed. The file on disk is what the reader sees. + if ln < 1: die(f"line {ln} is not in {f}: lines are numbered from 1") + n = self.file_lines(f) if files else None + if n is not None and ln > n: die(f"{f} has {n} line{'' if n == 1 else 's'}; line {ln} is not in it") # the smallest callable(s) spanning the line — several when they share it (`class Item { run(){} tag(){} }`); # top-level code belongs to the file's module node, which is a caller like any other r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) @@ -480,18 +519,22 @@ class G: shown = ', '.join(r['display'] for r in hits[:4]) + (' …' if len(hits) > 4 else '') return f"{len(ids)} declaration(s) containing '{s}' ({shown})", ids near = self.q("SELECT DISTINCT display FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL) AND (lower(display) = lower(?) OR lower(name) = lower(?) OR display LIKE ?) LIMIT 6", s, s.split('.')[-1], f"%{s.split('.')[-1]}%") + UNRESOLVED.append(s) # each refusal below: the [text] block searches for it (ax_text.py) # with candidates the advice is "pick one"; with none it has to be something else, or the line reads # "use one of them" pointing at an empty list + # a word the code WRITES but nothing declares (a local, a parameter, a property) is not a target: the methods that + # write it are named as leads, so the reader is not left at "nothing" when the word is right there in the code + leads = self.mentions(s) if near: - die(f"nothing named '{s}' in the graph. Exact names that are close: " - + ', '.join(r['display'] for r in near) + "\n use one of them as written, or file:line") + die(f"nothing named '{s}' is declared in this graph. Exact names that are close: " + + ', '.join(r['display'] for r in near) + "\n use one of them as written, or file:line" + leads) # the advice has to differ once the fragment form has ALREADY been tried, or it names the command just run if fragment: - die(f"no declaration in the graph contains '{s}'.\n" + die(f"no declaration in the graph contains '{s}'." + leads + "\n" f" a SHORTER fragment is the way in — `path {s[:max(3, len(s) - 2)]} '*'` — or a file:line from a\n" f" stack trace or an issue; and `axiomcode index` prints how many declarations the graph has at all.\n" ) - die(f"nothing named '{s}' in the graph, and nothing close to it.\n" + die(f"nothing named '{s}' is declared in this graph, and nothing close to it." + leads + "\n" f" a SHORTER fragment is the way in — `path {s.split('.')[-1][:6] or 'name'} '*'` matches every\n" f" declaration containing it — or a file:line from a stack trace or an issue.") @@ -591,15 +634,29 @@ class G: if w: ids += w[1]; parts.append(f"new {name} at {len(self.SITES[w[1][0]])} site(s)") lib = self.library(s + '.*') if '.' in s else self.library(name + '.*') if lib: ids += lib[1]; parts.append(f"{len(lib[1])} library method(s) of {s}") - callers = set() + callers = set(); typed = bool(ids) if self.has('type_refs'): for r in self.q("SELECT file, line FROM type_refs WHERE name = ? AND line > 0", name): - e = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY (end_line - line) LIMIT 1", r['file'], r['line'], r['line']) - if e: callers.add(e[0]['id']) + e = self.enclosing_at(r['file'], r['line']) + if e: callers.add(e); typed = True + # A NAME IS NOT A TYPE BECAUSE IT IS WRITTEN. An identifier reference says only that the word occurs: a local + # `const labelOf = new Map()`, a parameter, a field. Those uses were each counted as "a use of type labelOf", + # so a name no declaration carries came back as a type with exact callers and a passing `verified:` line. A + # reference whose binding the parser names as a value is dropped. What is left makes a type only when it is bound + # to one, or when the parser could not say what it is bound to and the name is spelt as every one of these + # languages spells a type (`Path.Combine(…)` in C#, where a library class is an unbound name): a member + # (`process.env.Path`) or a lower-case word (`jsx`) is not. Without that evidence (no `new X`, no library + # method, no type position either) the resolver goes on and says nothing is declared, listing the mentions as + # leads (`mentions`). if self.has('refs'): - for r in self.q("SELECT file, line FROM refs WHERE name = ? AND line > 0", name): - e = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY (end_line - line) LIMIT 1", r['file'], r['line'], r['line']) - if e: callers.add(e[0]['id']) + for r in self.q("SELECT file, line, kind, entity_kind FROM refs WHERE name = ? AND line > 0", name): + if (r['entity_kind'] or '') in VALUE_BINDINGS: continue + e = self.enclosing_at(r['file'], r['line']) + if not e: continue + callers.add(e) + typed = typed or r['entity_kind'] == 'TYPE' or (name[:1].isupper() and (r['kind'] or '') not in MEMBER_REFS + and self.unqualified_on(r['file'], r['line'], name)) + if not typed: return None if callers: node = f"t:{s}" self.sym[node] = {'id': node, 'display': f"uses of type {s}", 'file': '', 'line': None, 'end_line': None, 'kind': 'written', 'owner': '', 'is_test': 0, 'name': name, 'method_id': node} @@ -607,6 +664,33 @@ class G: ids.append(node); parts.append(f"referenced by name in {len(callers)} method(s)") if not ids: return None return f"type {s} (not declared here; matched: " + ', '.join(parts) + ")", ids + def enclosing_at(self, f, ln): + """the narrowest callable whose span holds file:line (not `enclosing`, which is a lambda's: two methods of one + name in one class, the later silently replaced the earlier and every lambda lookup died in a TypeError)""" + e = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY (end_line - line) LIMIT 1", f, ln, ln) + return e[0]['id'] if e else None + def unqualified_on(self, f, ln, name): + """the name is written on that line NOT as a member: `Path.join(…)`, not `HttpStatus.OK` / `process.env.PORT` + after a dot, nor `{ Bucket: … }` as an object key (a front end that records every identifier the same way does + not say which one is a member)""" + cache = self.__dict__.setdefault('_lines', {}); f = self.site_file(f) + if f not in cache: + try: cache[f] = open(os.path.join(self.repo, f), errors='replace').read().split('\n') + except OSError: cache[f] = None + L = cache[f] + if L is None or not 0 < ln <= len(L): return True # no source to read: as before + return re.search(rf'(? 0 ORDER BY file, line", name): + e = self.enclosing_at(r['file'], r['line']) + if e and e not in seen: seen[e] = (r['entity_kind'] or '').lower().replace('_', ' ') + if not seen: return '' + rows = [f" [by name] {self.disp(e)} {self.loc(e)}" + (f" — a {k} named {name}" if k in VALUE_KINDS_SHOWN else '') for e, k in list(seen.items())[:cap]] + return (f"\n the word is written in {len(seen)} method(s) — leads, not calls (no edge reaches a declaration of it):\n" + + '\n'.join(rows) + (f"\n … +{len(seen) - cap}" if len(seen) > cap else '')) def nested_miss(self, outer, inner, mem, s): """why Outer$Inner.m did not resolve: which of the three parts is wrong, with the exact alternatives""" outers = self.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND (display = ? OR display LIKE ? OR name = ?)", outer, f"%.{outer}", outer) @@ -1024,11 +1108,43 @@ def framework_note(g, ids, writers=()): return out +def why_no_caller(g, ids, shown=2, decls=3): + """` why nothing in the graph calls X: ; ` for up to `decls` of the methods in `ids`: the first + `shown` reasons graph_sql.no_caller_reasons gives, with the line that says so. A method with no reason prints nothing.""" + allsym = g.all_sym if hasattr(g, 'all_sym') else g.sym + mids = [i for i in ids if (allsym.get(i) or {}).get('method_id')] + src = {} + def lines(f): + if f not in src: + try: src[f] = open(os.path.join(g.repo, f), errors='replace').read().split('\n') + except OSError: src[f] = [] + return src[f] + try: + via = graph_sql.callers_via_base(g.q, mids[:decls], lines) if mids else {} + why = graph_sql.no_caller_reasons(g.q, [m for m in mids[:decls] if not via.get(m)]) + except Exception: return [] + out = [] + for i in mids[:decls]: + rs = why.get(i) or [] + if via.get(i): + # a caller through an interface or base method the engine narrowed to one implementation: `impact` lists it + # as `calls it (via the interface)`, and the closure, which walks call edges, has no edge to start from + cs = sorted(via[i], key=lambda c: (allsym.get(c) or {}).get('display') or c) + out.append(f" {g.disp(i)} has {len(cs)} caller(s) through the interface or base method it is declared on, which the " + "closure does not walk: " + ', '.join(f"{g.disp(c)} {g.loc(c)}" for c in cs[:4]) + (' …' if len(cs) > 4 else '') + + f"; `impact {g.disp(i)}` lists them") + elif rs: + out.append(f" why nothing in the graph calls {g.disp(i)}: " + '; '.join( + graph_sql.no_caller_phrase(k, l) + (f" ({at})" if at else '') for k, l, at in rs[:shown]) + + (f" (+{len(rs) - shown} more: `impact {g.disp(i)}`)" if len(rs) > shown else '')) + return out + + def closure(g, sel, upstream, limit=40, depth=40): g.export(); label, ids = g.resolve(sel, fragment=True) res = run(g, {'c': (ids if not upstream else [], ids if upstream else [])}) rows = [(m, int(d)) for m, d in (res['dist_up'] if upstream else res['dist'])['c'] if 0 < int(d) <= depth and m in g.sym] - if g.IN: rows = [(m, d) for m, d in rows if g.IN in (g.sym[m]['file'] or '')] # --in: only the part of the closure under that path + if g.IN: rows = [(m, d) for m, d in rows if g.under_in(g.sym[m]['file'])] # --in: only the part of the closure under that path if not upstream: rows = [(m, d) for m, d in rows if g.sym[m]['kind'] not in ('library', 'written')] # downstream: library ends are listed separately, not counted as reached methods # second implementation: a plain BFS over the same facts, in the same direction, must give the same set and depths adj = collections.defaultdict(set) @@ -1040,7 +1156,7 @@ def closure(g, sel, upstream, limit=40, depth=40): for y in adj.get(x, ()): if y not in seen: seen[y] = d; nxt.append(y) fr = nxt - bfs = {m: dd for m, dd in seen.items() if 0 < dd <= depth and m in g.sym and (not g.IN or g.IN in (g.sym[m]['file'] or '')) and (upstream or g.sym[m]['kind'] not in ('library', 'written'))} + bfs = {m: dd for m, dd in seen.items() if 0 < dd <= depth and m in g.sym and g.under_in(g.sym[m]['file']) and (upstream or g.sym[m]['kind'] not in ('library', 'written'))} agree = bfs == dict(rows) word = 'can reach' if upstream else 'is reached from' print(f"{'everything that ' + word + ' ' if upstream else 'everything reached from '}{label}: {len(rows)} method(s) in {len({g.sym[m]['file'] for m, _ in rows})} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '')) @@ -1086,6 +1202,10 @@ def closure(g, sel, upstream, limit=40, depth=40): # a signal receiver or a CLI command the answer "0 methods reach it" is true of calls and false of the # program: the framework reaches it. Name the registration rather than leave the reader at a dead end. for line in (framework_note(g, ids) if upstream else []): print(line) + # and WHY, from the reader the hooks' `← ?` label and impact's `next:` read (graph_sql.no_caller_reasons): the + # first two reasons of each declaration, so `path '*' X` never says less than a Read of X does + if upstream: + for line in why_no_caller(g, ids): print(line) # A word that resolves to a TYPE has no outbound calls by construction — a type declares members, it does # not call anything — so `path '*'` is empty for every type, declared or not. That is the form the # guide gives someone who has no name yet, and a large share of the words they will try are type-valued @@ -1125,17 +1245,25 @@ def closure(g, sel, upstream, limit=40, depth=40): # which is not the line that makes the call, and a reader checking the hop wants the second. For a # direct caller that line is knowable exactly, so print it; beyond one hop there is no single site. qmark = ','.join('?' * len(ids)) + # a chained route link (`router.get('/a', h1).post('/b', h2)`) is written on its own line, not its receiver's + link_line = {} + if any(d == 1 for _, d in np_[:limit]): + try: + import ax_registration + link_line = ax_registration.route_site_lines(g.q, g.site_file) + except Exception: + pass for m, d in np_[:limit]: at = '' if d == 1: - r = g.q("SELECT s.file_path f, s.start_line ln FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " - f"WHERE e.caller_id = ? AND e.callee_method_id IN ({qmark}) AND s.start_line IS NOT NULL " - "ORDER BY s.start_line LIMIT 1", m, *ids) + r = g.q("SELECT s.id sid, s.file_path f, s.start_line ln FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + f"WHERE e.caller_id = ? AND e.callee_method_id IN ({qmark}) AND s.start_line IS NOT NULL", m, *ids) if r: - sf = g.site_file(r[0]['f']) + ln, f = min((link_line.get(x['sid'], x['ln']), x['f'] or '') for x in r) + sf = g.site_file(f) # the call is nearly always written in the caller's own file, and repeating a 90-character # path to say so pushes the name off the line. Same file, just the line. - at = f" — calls it at {':' if sf == (g.sym[m]['file'] or '') else sf + ':'}{r[0]['ln']}".replace('at ::', 'at :') + at = f" — calls it at {':' if sf == (g.sym[m]['file'] or '') else sf + ':'}{ln}".replace('at ::', 'at :') print(f" {d:2} hop(s) {g.disp(m)} {g.loc(m)}{at}") if len(np_) > limit: print(f" … +{len(np_) - limit} production (--limit N)") if nt: print(f" +{len(nt)} test caller(s) within 2 hop(s) — `axiomcode test-impact` names them and the command that runs them") @@ -1228,81 +1356,47 @@ def entered_in_process(g, ids): # into "local", "tests: 0" or "independent" was false on exactly these declarations. The rule is one rule, with no # framework's names in it: every decoration counts except the short list below, which change nothing about who calls # the declaration (a compiler check, a nullness mark, a generated accessor, a wrapper around the same call). -INERT_DECORATIONS = { - # compiler checks and documentation - 'Override', 'SuppressWarnings', 'Deprecated', 'Serial', 'FunctionalInterface', 'SafeVarargs', 'Documented', - 'Retention', 'Target', 'Inherited', 'Obsolete', 'Serializable', 'Flags', 'Pure', 'MethodImpl', 'AttributeUsage', - 'Generated', 'GeneratedCode', 'CompilerGenerated', 'ExcludeFromCodeCoverage', 'DebuggerStepThrough', - 'DebuggerNonUserCode', 'DebuggerDisplay', 'DebuggerHidden', 'CallerMemberName', 'CallerFilePath', 'CallerLineNumber', - 'override', 'overload', 'final', 'abstractmethod', 'deprecated', - # nullness - 'Nullable', 'NonNull', 'Nonnull', 'NotNull', 'CheckForNull', 'NotNullWhen', 'MaybeNullWhen', 'AllowNull', 'DisallowNull', - # members the compiler writes, and wrappers the caller still calls by name - 'staticmethod', 'classmethod', 'property', 'cached_property', 'hybrid_property', 'wraps', 'lru_cache', 'cache', - 'cached', 'memoize', 'dataclass', - 'total_ordering', 'contextmanager', 'asynccontextmanager', - 'Getter', 'Setter', 'Data', 'Builder', 'ToString', 'EqualsAndHashCode', 'AllArgsConstructor', 'NoArgsConstructor', - 'RequiredArgsConstructor', 'Slf4j', 'Log4j2', -} -BASE_CONTEXTS = ('BASE_LIST', 'BASE_CLASS', 'SUPER_TYPE', 'IMPLEMENTS_INTERFACE') -_NOT_A_BASE = {'object', 'Object', 'string', 'String', 'int', 'bool', 'long', 'double', 'float', 'decimal', 'byte', 'char', 'short'} - -def _dec_name(n): - n = (n or '').lstrip('@').split('(')[0].split('.')[-1] - return n[:-len('Attribute')] if n.endswith('Attribute') and len(n) > len('Attribute') else n +INERT_DECORATIONS = graph_sql.INERT_DECORATIONS # one list, read by the hooks and impact too +BASE_CONTEXTS = graph_sql.BASE_CONTEXTS +_dec_name = graph_sql.decoration_name def _library_bases(g, tid): - """the base types written on type symbol `tid` that are not declared in the graph: a library ancestor the engine - resolved (types.provenance <> 'client'), or a written base name no client type carries (the parser could not - resolve it). Only the type's OWN header: a base written on a nested type belongs to the nested type.""" - s = g.all_sym.get(tid) if hasattr(g, 'all_sym') else g.sym.get(tid) - if not s: return [] - out = [] - if s.get('type_id') and g.has('type_ancestors') and g.has('types'): - out += [r[0] for r in g.q("""SELECT DISTINCT x.name FROM type_ancestors a JOIN types x ON x.id = a.ancestor_type_id - WHERE a.type_id = ? AND x.provenance <> 'client' ORDER BY x.name""", s['type_id'])] - if s.get('file') and s.get('line') and g.has('type_refs'): - end = s.get('end_line') or s['line'] - nested = [(r[0], r[1] or r[0]) for r in g.q("""SELECT line, end_line FROM symbols WHERE file = ? AND method_id IS NULL - AND type_id IS NOT NULL AND line > ? AND line <= ?""", s['file'], s['line'], end)] - ph = ','.join('?' * len(BASE_CONTEXTS)) - for name, line in g.q(f"SELECT name, line FROM type_refs WHERE file = ? AND line BETWEEN ? AND ? AND context IN ({ph})", - s['file'], s['line'], end, *BASE_CONTEXTS): - if any(a <= line <= b for a, b in nested): continue - simple = re.split(r'[<(\[]', name or '')[0].split('.')[-1] - if not simple or simple in _NOT_A_BASE: continue - if g.has('types') and g.q("SELECT 1 FROM types WHERE name = ? AND provenance = 'client' LIMIT 1", simple): continue - out.append(name) - return list(dict.fromkeys(out)) + """the base types written on type symbol `tid` that are not declared in the graph (graph_sql.library_bases)""" + return graph_sql.library_bases(g.q, g.all_sym.get(tid) if hasattr(g, 'all_sym') else g.sym.get(tid)) def unmodelled_entry(g, ids): """[(symbol id, signal)]: for each declaration in `ids` (a method, a field or a type) that is not a test, what says - something outside the code may call it with no edge in this graph -- a decoration outside INERT_DECORATIONS on it - or on its owner type, or a base type of its owner (of itself, for a type) the graph does not contain. A private or - static member is never entered through its owner's decoration or base, so only its own decorations count.""" + something outside the code may call it with no edge in this graph -- a decoration a framework reads on it or on its + owner type, or a base type of its owner (of itself, for a type) the graph does not contain. For a method the signals + are graph_sql.no_caller_reasons, the reader the hooks' `← ?` label and path's empty-upstream note read, so the three + name the same thing; a wrapper (a cache, a permission check, a decorator the repository declares) is not one. A + private or static member is never entered through its owner's decoration or base, so only its own decorations count.""" allsym = g.all_sym if hasattr(g, 'all_sym') else g.sym decs = lambda i: [n for n in dict.fromkeys(_dec_name(r[0]) for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", i)) if n and n not in INERT_DECORATIONS] if g.has('decorations') else [] out = [] + mids = [i for i in ids if (allsym.get(i) or {}).get('method_id') and not allsym[i].get('is_test')] + why = graph_sql.no_caller_reasons(g.q, mids) if mids else {} for i in ids: s = allsym.get(i) if not s or s.get('is_test'): continue + if i in why: + own = s.get('owner') or '' + for kind, label, _at in why[i]: + # the decoration by its simple name here, as this signal always said it (`@on_boot`, not `@hooks.on_boot`) + if kind in ('registered', 'framework'): out.append((i, f"@{_dec_name(label.split(' ')[0])} on {s['display']}")) + elif kind == 'type decoration': out.append((i, f"@{_dec_name(label.rsplit(' on ', 1)[0])} on its type {own}")) + elif kind == 'base': out.append((i, f"{own} extends {label}, which the graph does not contain")) + continue for n in decs(i): out.append((i, f"@{n} on {s['display']}")) is_type = s.get('type_id') and not s.get('method_id') if is_type: for b in _library_bases(g, i): out.append((i, f"{s['display']} extends {b}, which the graph does not contain")) continue - if s.get('method_id') and g.has('methods'): - row = g.q("SELECT kind, visibility FROM methods WHERE id = ?", s['method_id']) - if row and (any(k in (row[0][0] or '').upper() for k in ('STATIC', 'INITIALIZER')) or (row[0][1] or '').upper() == 'PRIVATE'): continue - # where the language records no visibility, a leading underscore is how private is written - if row and not row[0][1] and re.match(r'_(?!_\w*__$)', s.get('name') or ''): continue own = s.get('owner') if not own: continue for r in g.q("SELECT id FROM symbols WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL", own): for n in decs(r[0]): out.append((i, f"@{n} on its type {own}")) - if s.get('method_id') and s.get('kind') != 'constructor': - for b in _library_bases(g, r[0]): out.append((i, f"{own} extends {b}, which the graph does not contain")) return list(dict.fromkeys(out)) def grep_roots(g): @@ -1491,7 +1585,11 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): f"{grep_for(g, g.sym[unmod[0][0]]['name'])}" if unmod else ": the two are independent in this graph")) WHY = {'parameter': 'a parameter', 'loop_variable': 'a loop variable', 'local': 'a local', 'computed': 'a computed member', - 'dynamic_code': 'code built at run time'} + 'dynamic_code': 'code built at run time', 'module_variable': 'a module-level variable', + 'field': 'a function stored in a field', + 'instance_member': 'a function stored on the instance from outside the class', + 'parameter_member': 'a member of a parameter', 'expression': 'computed by an expression', + 'imported_variable': "another module's exported variable, which that module reassigns"} for lx, rows in opaque: print(f" {lx} reaches {len(rows)} call(s) through a value:") for r in rows[:4]: @@ -1566,11 +1664,22 @@ if __name__ == '__main__': if args[0] == '*' and args[1] == '*': die("one endpoint must be named: path '*' X (everything that reaches X) or path X '*' (everything X reaches)") def answer(): - g = G(args[2] if len(args) > 2 else '.'); g.IN = IN + global IN + repo = args[2] if len(args) > 2 else '.' + # an --in no graph holds is answered at the root, and says so (the same rule as impact's) + if IN and not ax_text.held(repo, IN): + print(f"{ax_text.SCOPE_GONE}'{IN}' in its path — answering at the repository root instead") + IN = None + g = G(repo); g.IN = IN RESULT['query'] = {'from': args[0], 'to': args[1], 'repo': g.repo} - if args[0] == '*': return closure(g, args[1], upstream=True, limit=limit if limit != 10 else 40, depth=depth) - if args[1] == '*': return closure(g, args[0], upstream=False, limit=limit if limit != 10 else 40, depth=depth) - return path(g, args[0], args[1], show_all, limit, every, max_paths) + try: + if args[0] == '*': return closure(g, args[1], upstream=True, limit=limit if limit != 10 else 40, depth=depth) + if args[1] == '*': return closure(g, args[0], upstream=False, limit=limit if limit != 10 else 40, depth=depth) + return path(g, args[0], args[1], show_all, limit, every, max_paths) + except SystemExit as e: + # an endpoint nothing in the graph declares: what a search by hand would find, labelled [text] (ax_text.py) + if e.code and UNRESOLVED and not as_json: sys.stdout.flush(); ax_text.emit(repo, UNRESOLVED, IN) + raise if not as_json: sys.exit(answer()) # the prose goes to a buffer, not to the caller: one code path produces both, and `prose` is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 388fdab54..993e26091 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -60,6 +60,7 @@ TESTY = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) +import graph_sql def run(script, args): @@ -122,6 +123,10 @@ def concrete_test_classes(db, names): # How a suite is invoked, per language. Only the shapes this graph is built for; anything else prints the # files and lets the reader decide, which is better than inventing a command that does not run. def command_for(lang, files, classes, db=None, repo='.'): + # a runner is handed only files of its own language: a text tier can name a .cs or .ts fixture beside a Python + # test, and `pytest Foo.cs` runs nothing the answer meant + if lang in LANG_EXT: + files = [f for f in files if f.endswith(LANG_EXT[lang])] if lang in ('java', 'csharp') and db: # a class to select by name: the test's owner, else its file's basename (a Java or C# test class is its file) names = classes or [os.path.splitext(os.path.basename(f))[0] for f in files] @@ -145,12 +150,133 @@ def command_for(lang, files, classes, db=None, repo='.'): return 'dotnet test --filter "' + "|".join(f"FullyQualifiedName~{n}" for n in sorted( {_os.path.splitext(_os.path.basename(f))[0] for f in files})[:40]) + '"' if lang == 'python': - return "pytest " + " ".join(sorted(files)[:40]) + return "\n".join(py_plan(repo, files)[0]) or None if lang in ('typescript', 'javascript'): return "\n".join(js_plan(repo, files)[0]) or None return None +LANG_EXT = {'python': ('.py',), 'java': ('.java',), 'csharp': ('.cs',)} +LANG_FAMILY = {'python': 'py', 'java': 'java', 'csharp': 'cs', 'typescript': 'js', 'javascript': 'js'} +PY_TEST_FILE = re.compile(r'(^|/)(test[^/]*|[^/]*_test)\.py$') # what pytest, unittest and Django collect by default + + +def collected_by(lang, rel): + """whether `lang`'s runner would run the file `rel` as a test module: a text match is a lead on any file, a command + names only test modules of its own language (TypeScript and JavaScript are placed by js_plan, which says so)""" + if lang == 'python': return bool(PY_TEST_FILE.search(rel)) + if lang in LANG_EXT: return rel.endswith(LANG_EXT[lang]) + return lang in ('typescript', 'javascript') and family(rel) == 'js' + + +# A Python test file is run by whatever its PROJECT runs tests with, and pytest is only one of three. A Django project +# with no pytest anywhere runs `python manage.py test`, which takes dotted module labels; a unittest project runs +# `python -m unittest`; handing either one `pytest tests/x.py` fails (pytest is not installed) or runs without what the +# project set up (settings, a test database). So the runner is read off the project the file belongs to, in the order +# a project states it: a pytest config section or conftest.py, the command its CI / tox / Makefile runs, its declared +# dependencies, the test file's own imports, and only then the shape of the project (a Django manage.py) and of the +# file (a TestCase). With nothing to go on it stays pytest, which also runs unittest classes. +PY_MARKERS = ('pyproject.toml', 'setup.cfg', 'setup.py', 'tox.ini', 'pytest.ini', 'manage.py', 'requirements.txt', 'Pipfile') +PY_DEPS = ('pyproject.toml', 'setup.cfg', 'setup.py', 'Pipfile', 'tox.ini', 'environment.yml') +PY_CMDS = ('tox.ini', 'Makefile', 'noxfile.py', 'justfile', '.gitlab-ci.yml', '.travis.yml') +PYTEST_WORD = re.compile(r'(?` on a file vitest does not collect @@ -1304,6 +1430,10 @@ def js_plan(repo, files): cmd = _script_for(repo, pkg, f[len(pkg) + 1:] if pkg else f) if cmd: singles.append((pkg, cmd)) + elif graph_sql.script_test_file(f, t := _read(os.path.join(repo, f), 400_000)) and graph_sql.script_main_guard(f, t): + # a script test no package script or usage line names runs under its interpreter only when it guards its + # own entry; an unguarded one is named as run by nothing (#1570), never a guessed framework or interpreter + singles.append(('', f"{'npx tsx' if f.endswith(('.ts', '.mts', '.cts', '.tsx')) else 'node'} {shlex.quote(f)}")) else: unrun.append(f) def cd(pkg, cmd): @@ -1316,8 +1446,21 @@ def js_plan(repo, files): return cmds, unrun +def script_command(repo, rel): + """the command a script test is run by, the one this command prints for it (impact labels the test with it)""" + plan = py_plan if rel.endswith('.py') else js_plan + return "\n".join(plan(repo, [rel])[0]) or None + + def print_unrun(lang, repo, files): """Say which selected files no runner collects, so a reader does not take the command as the whole run.""" + if lang == 'python': + unrun = py_plan(repo, [f for f in files if f.endswith('.py')])[1] + if unrun: + print(f"\n {len(unrun)} selected file(s) declare no test and do not run as a program (a fixture or helper under" + " a test tree): no command is given for them; the tests that import them are the ones to run: " + + ', '.join(unrun[:6]) + (f" … +{len(unrun) - 6}" if len(unrun) > 6 else '')) + return if lang not in ('typescript', 'javascript'): return unrun = js_plan(repo, files)[1] if unrun: @@ -1390,7 +1533,9 @@ def stems_of(rel): for suf in _STEM_SUF: if s.endswith(suf) and len(s) > len(suf): out.add(s[:-len(suf)]) - return {x for x in out if len(x) > 2} + # a name every package repeats is no module's name: __init__.py, __main__.py and conftest.py are in every test tree, + # and "named after __init__" once listed fifteen of them as the tests of one + return {x for x in out if len(x) > 2 and not re.fullmatch(r'__\w+__|conftest', x)} def family(rel): @@ -1399,6 +1544,36 @@ def family(rel): return {'.py': 'py', '.pyi': 'py', '.java': 'java', '.cs': 'cs'}.get(e, 'js' if e in ('.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx') else ('py' if not e else e)) +# Being under a test directory and sharing a basename is not being a test. `test/fixtures/basic/index.js` is an +# INPUT a test reads, `tests/test_index.py` is another language's test, and the changed file shares its own name: +# all three were offered as "the tests of src/index.js", with a runner command. A by-name candidate is a test +# only by the convention of the changed file's own ecosystem -- the pattern its runner collects by default. +_CODE_EXT = ('.py', '.java', '.cs', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs') +_TEST_AFFIX = { # (prefixes, suffixes) of the file stem, per runner default + 'py': (('test_',), ('_test',)), # pytest python_files + 'java': (('Test', 'IT'), ('Test', 'Tests', 'TestCase', 'IT')), # surefire / failsafe includes + 'cs': ((), ('Test', 'Tests')), + 'js': ((), ('.test', '.spec')), # jest / vitest testMatch +} +# a file DIRECTLY in one of these is collected whatever its name (mocha ./test/*.js, jest __tests__/); +# one nested deeper (test/fixtures/x/index.js) is not +_TEST_DIR_COLLECTS = {'js': ('__tests__', 'test', 'tests')} + + +def tested_stems(rel): + """The module names `rel` is the test of by its ecosystem's own convention; empty when it is no test.""" + fam = family(rel) + s = os.path.splitext(os.path.basename(rel))[0] + pre, suf = _TEST_AFFIX.get(fam, ((), ())) + # a Java prefix is a word of its own (TestParser), not the start of one (Testimonial) + out = {s[len(p):] for p in pre if s.startswith(p) and len(s) > len(p) + and (fam != 'java' or s[len(p)].isupper())} + out |= {s[:-len(x)] for x in suf if s.endswith(x) and len(s) > len(x)} + if os.path.basename(os.path.dirname(rel)) in _TEST_DIR_COLLECTS.get(fam, ()): + out.add(s) + return {x for x in out if len(x) > 2} + + def package_tier(repo, changed_files, limit=6000): """Two tiers of proximity, for when the call graph named nothing. Neither is a call-graph answer. @@ -1412,27 +1587,35 @@ def package_tier(repo, changed_files, limit=6000): """ pkgs = {package_of(f) for f in changed_files if f} pkgs.discard('') + # a candidate is a test of a changed file when it is a test by the convention of THAT file's language and + # names that file's module; anything else sharing the name is counted, not offered + changed = {f for f in changed_files if f} + want = {(family(f), os.path.splitext(os.path.basename(f))[0]) for f in changed} want_stems = set() - for f in changed_files: - if f: want_stems |= stems_of(f) + for f in changed: + want_stems |= stems_of(f) if not pkgs and not want_stems: return {}, {}, pkgs - fams = {family(f) for f in changed_files if f} by_pkg = collections.defaultdict(list) - by_name = [] + by_name, not_tests = [], 0 seen = 0 + + def named(): + return {'files': sorted(by_name), **({'not_tests': not_tests} if not_tests else {})} if by_name or not_tests else {} for root, dirs, fs in os.walk(repo): dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode')] for f in fs: - if not f.endswith(('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) + if not f.endswith(_CODE_EXT): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') if not TESTY.search(rel) or rel in changed_files: continue # a changed test file is run as edited, not found here seen += 1 - if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs - # a test named after a changed file in ANOTHER language is not its test (server.js for server.py) - if stems_of(rel) & want_stems and family(rel) in fams: by_name.append(rel) + if seen > limit: return dict(by_pkg), named(), pkgs + # a test of a changed file by the convention of that file's own ecosystem (TypeScript and JavaScript read + # each other); a same-named file that is not one -- fixture input, another language's test -- is only counted + if any((family(rel), s) in want for s in tested_stems(rel)): by_name.append(rel) + elif stems_of(rel) & want_stems: not_tests += 1 p = package_of(rel) if p in pkgs: by_pkg[p].append(rel) - return dict(by_pkg), ({'files': sorted(by_name)} if by_name else {}), pkgs + return dict(by_pkg), named(), pkgs CODE = ('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs') @@ -1472,11 +1655,14 @@ def needles(rel, program=False): return [n for n in dict.fromkeys(out) if len(n) > 3] -def loaders(repo, wanted, edited=()): +def loaders(repo, wanted, edited=(), lang=None, others=None): """{changed file: (the needle that matched, [test files naming it])} for files no call edge can reach a test from: changed files outside every indexed language, and code that also runs as a program. The most specific needle any test names wins, and only the tests naming it are kept, so a bare directory name is used only when nothing names the - file itself. A text match, not a call: printed as its own tier.""" + file itself. A text match, not a call: printed as its own tier. With `lang` (the graph's language), only that language's + files are read, BEFORE the most specific needle is chosen: another language's test naming the full path must not + outrank this language's test naming the file (`others` maps each file to the other-language tests naming it), and a + test module the runner collects outranks a helper or fixture beside it that names the file more fully.""" if not wanted: return {} want = {f: needles(f, prog) for f, prog in wanted.items()} # a path or a file name stands alone (not inside a longer name); a bare word (a directory, a program's name) only @@ -1484,15 +1670,25 @@ def loaders(repo, wanted, edited=()): pats = {n: re.compile((r'(? needle index -> [tests] + runs = {f: {} for f in wanted} # the same, for test modules the runner collects for t, text in test_texts(repo): if t in wanted or t in edited: continue + if fam and family(t) != fam: + if others is not None: + for f, ns in want.items(): + if any(n in text and re.search(pats[n], text) for n in ns): others.setdefault(f, set()).add(t) + continue for f, ns in want.items(): for k, n in enumerate(ns): if n in text and re.search(pats[n], text): - hits[f].setdefault(k, []).append(t); break + hits[f].setdefault(k, []).append(t) + if fam and collected_by(lang, t): runs[f].setdefault(k, []).append(t) + break out = {} - for f, by in hits.items(): + for f in wanted: + by = runs[f] or hits[f] if by: k = min(by); out[f] = (want[f][k], sorted(by[k])) return out @@ -1590,7 +1786,14 @@ def main(argv): try: txt = open(os.path.join(repo, f), errors='replace').read(400_000) except OSError: continue if PROGRAM.search(txt): progs[f] = True - loaded = loaders(os.path.realpath(repo), dict({f: False for f in outside}, **progs), edited) if (outside or progs) else {} + # ONE LANGUAGE PER ANSWER. This answer is one language's graph (a repository in several has one graph each, and each + # is asked on its own): a test file of another language that names the file is that language's answer to give, and + # read here it once put .cs, .java and .ts fixtures on a `pytest` line. It is counted, not listed. + glang = (changed.get('lang') or '').lower() or _guess_lang([e.get('file', '') for e in entries if e.get('file', '').endswith(CODE)]) + other_by = {} + loaded = loaders(os.path.realpath(repo), dict({f: False for f in outside}, **progs), edited, glang, other_by) \ + if (outside or progs) else {} + other_lang = set().union(*other_by.values()) if other_by else set() loaded_files = sorted({t for _, ts in loaded.values() for t in ts}) if not targets and not edited and not loaded and not outside: @@ -1615,6 +1818,7 @@ def main(argv): unreadable, notes = [], [] pulled = collections.defaultdict(set) unseen = [] + out_src = None # a test tree the graph's --src left out (impact says so) batches = [[t] for t in targets] if len(targets) <= 8 else [targets[k:k + 60] for k in range(0, len(targets), 60)] disp = {by_target[t].get('symbol'): t for t in targets} for batch in batches: @@ -1642,6 +1846,7 @@ def main(argv): for rec in d.get('framework_entries') or []: fw_entries.setdefault(rec.get('id') or rec.get('display'), rec) if d.get('framework_grep') and d['framework_grep'] not in fw_greps: fw_greps.append(d['framework_grep']) + if d.get('tests_outside_src'): out_src = d['tests_outside_src'] # a batch of several declarations breaks a stub when any of them is a signature change or a removal breaks = any((by_target[t].get('kind') or '') in STUB_BREAKING for t in batch) bt = batch[0] if len(batch) == 1 else f"{len(batch)} declarations" @@ -1670,7 +1875,7 @@ def main(argv): # A row the answer named that carries no owner still has to reach the reader. Dropping it for being # malformed is how a selection gets quietly shorter, which is the one failure this command exists to # prevent — the same shape as a dropped edge or an answer that could not be parsed. - ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at')] + ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at') and not r.get('script')] # a script is its file run_files = sorted(set(files) | set(edited)) lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) # the graph `impact` answered from, asked again for which selected classes are abstract (command_for) @@ -1698,8 +1903,11 @@ def main(argv): 'same_package_only': pkg_hits, 'same_name_only': name_hits.get('files') or [], 'outside_index': outside, + **({'tests_outside_src': out_src} if out_src and not tests else {}), 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, + 'named_in_other_language_tests': len(other_lang), 'range_note': changed.get('range_note'), + 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, indent=1)) @@ -1716,7 +1924,13 @@ def main(argv): print("\nno test in the graph reaches any of them.") print(" that is not the same as 'no test covers this': a function invoked through an array, a") print(" table or a plugin list has no resolved caller, so a test that drives it looks unrelated.") + if out_src: + fs = out_src.get('files') or [] + print(f" NOT SEEN: the graph was indexed with --src {out_src.get('src')}, and {len(fs)} test file(s) lie outside it (" + + ', '.join(fs[:3]) + (f" … +{len(fs) - 3}" if len(fs) > 3 else '') + "): no test there was looked at." + " Index without --src (or with one that holds the tests) before reading 'no test' as an answer.") nf = name_hits.get('files') or [] + cmd = None if nf: print(f"\n BY NAME ONLY — {len(nf)} test file(s) are NAMED AFTER a changed file") print(" (test_x.py for x.py, XTest.java for X.java). This is the naming convention, not the") @@ -1730,6 +1944,9 @@ def main(argv): if cmd: print("\n " + cmd.replace("\n", "\n ")) print_unrun(nlang, repo, nf[:limit]) + if name_hits.get('not_tests'): + print(f"\n not a test: {name_hits['not_tests']} same-named file(s) under test data or in another" + " language — left out, and no command runs them") if pkg_hits: n = sum(len(v) for v in pkg_hits.values()) print(f"\n BY PACKAGE ONLY — {n} test file(s) live under the same package as a changed file") @@ -1765,7 +1982,7 @@ def main(argv): elif edited and cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) - if outside or loaded: + if outside or loaded or other_lang: # NOT A CALL: a file no graph reads, or a program a test starts by name. The tests that name it are the lead if outside: print(f"\n{len(outside)} changed file(s) outside every indexed language (no declaration to follow): " @@ -1774,10 +1991,21 @@ def main(argv): print(f" [text] {len(loaded_files)} test file(s) name them in their text — they load or start them; run these too:") for f, (n, ts) in sorted(loaded.items()): print(f" {f} named as '{n}' by: " + ", ".join(ts[:6]) + (f" … +{len(ts) - 6}" if len(ts) > 6 else "")) - both = sorted(set(run_files) | set(loaded_files)) - cmd = command_for(_guess_lang(both) or lang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else [], db, repo) + # the runner of the graph's language, handed only the files it collects: the text tier matches path words in + # every file of a test tree, fixtures in other languages included, and a majority of those once chose the + # runner and put .cs and .ts fixtures on a `pytest` line + rlang = lang or _guess_lang(run_files or loaded_files) + runnable = [f for f in loaded_files if collected_by(rlang, f)] + both = sorted(set(run_files) | set(runnable)) + cmd = command_for(rlang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in runnable] if classes else [], db, repo) if cmd: print("\n with the tests above: " + cmd.replace("\n", "\n ")) - missed = [f for f in outside if f not in loaded] + if len(runnable) < len(loaded_files): + print(f" ({len(loaded_files) - len(runnable)} of the files naming them are not {rlang or 'runnable'} test files" + " (fixtures, case data, another language's tests), so no command above runs them)") + if other_lang: + print(f" {len(other_lang)} test file(s) in another language name them too; that language's graph answers for them" + f" (this answer is the {glang} graph's)") + missed = [f for f in outside if f not in loaded and f not in other_by] if missed: print(f" no test names {', '.join(missed[:6])}{' …' if len(missed) > 6 else ''} in its text: which suite reads " "it is not visible here, so 'no test' is NOT an answer for it — grep the test tree for its directory") @@ -1828,6 +2056,10 @@ def main(argv): print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") + if not tests and name_hits.get('files') and cmd: + # the generic next step says "only the tests above", which a by-name lead has not earned + print(f"next: run {cmd} first — named after the changed file, not reached through the graph; " + "then run the rest of the suite") return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 04014cdbe..e659628e2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -302,7 +302,8 @@ direct(q, c, "uses", cat("serves what this sends across a process boundary (", c // a provider does not depend on the handler asking for it, so it is claimed for none. // Certainty is `framework`, never `resolved`: the reason carries the mechanism, what joined the two ends and // the engine's own confidence (`registered`, or `by_name` for a fixture), so a reader can tell the hop from -// an invocation. A direct row only, not a seed of the closure: the contract `remote` keeps, for the same reason. +// an invocation. A direct row only, not a seed of the closure. (`remote` is walked by the closure, as the fw_edge +// tier `remote` below, from the sender to the handler only.) .decl framework(f:symbol, t:symbol, mech:symbol, detail:symbol, conf:symbol) .input framework direct(q, c, "uses", cat("framework-mediated, not a call: ", cat(mech, cat(" via ", cat(detail, cat(" [", cat(conf, "]")))))), "framework", "", 0) :- target(q, "method", m, _), framework(c, m, mech, detail, conf), c != m. @@ -876,6 +877,12 @@ fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b dec_decl(d, w) :- decorated(_, d), named(d, w), 1 = count : { named(d, _) }. dec_decl(d, w) :- decorated(s, d), named(d, w), decl_file(s, f), decl_file(w, f). fw_edge(a, b, "decorator by name") :- decorated(a, d), dec_decl(d, b), a != b. +// A CLIENT ACROSS A PROCESS: the sender depends on the handler it reaches (#1293). `remote` above makes the client a +// DIRECT row; walked only there, a test that drives the client never reached a change to the handler, so +// test-impact stopped at the route while impact named the client one line up. Walked here as its own tier, the +// test route carries the `remote` rung, so it is never read as a call chain. One direction only: a handler does +// not depend on who sends to it. +fw_edge(a, b, "remote") :- remote(a, b, _, _, _), a != b. // THE PROTOCOL MEMBERS OF A TYPE YOU CONSTRUCT. `with Audit(...) as a`, `for line in audit`, `a == b`, `len(x)`, // `x[k]` — each runs a method, and none of them writes its name, so `__enter__`, `__iter__`, `__eq__` and their // siblings have no call site at all and a test that drives them looks unrelated. The one thing the graph does see diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 7caff1d4a..3f51c1f07 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -549,6 +549,54 @@ def parent_up(edges, depth): EACH_TABLE = re.compile(r'\b(it|test|bench|describe)\s*\.\s*each\b') +# A SCRIPT TEST: a file under the test tree that calls no test framework and is run as a program — `test/run.js` +# calling `runCase(...)` at top level, a `tests/check.py` with its own main guard. Its module IS the test: impact walked +# to it and labelled it [test], then counted "0 of 0", and test-impact said no test reaches the change. A file that +# declares a framework test is its runner's; one that exports (a helper), sits in a fixture or support directory, or +# is a runner's setup or config file is not run on its own. Nor is test DATA: the inputs a test reads (a case +# directory, a fixture project with its own src/, a corpus) and the tooling beside the tests (tools/, bin/, a harness) +# lie under a test tree too, and on one repository they were two thirds of what the rule matched before it said so. +PY_SCRIPT_MAIN = re.compile(r'^if\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:', re.M) +JS_EXPORTS = re.compile(r'^\s*(?:export\s|module\.exports\b|exports\.[\w$]+\s*=)', re.M) +JS_FRAMEWORK = re.compile(r'(? `route`, `HttpGetAttribute` -> `HttpGet`: the simple name a decoration is known by""" + n = (n or '').lstrip('@').split('(')[0].split('.')[-1] + return n[:-len('Attribute')] if n.endswith('Attribute') and len(n) > len('Attribute') else n + + +def library_bases(q, s): + """the base types written on type symbol `s` (a dict with type_id, file, line, end_line) that the graph does not + declare: a library ancestor the engine resolved (types.provenance <> 'client'), a base the Python engine could not + resolve (ext_type_base_unresolved), or a base written in the header that no client type carries (C#'s BASE_LIST, + which the parser could not resolve). Only the type's OWN header: a base written on a nested type is the nested one's.""" + if not s: return [] + out = [] + if s.get('type_id') and _has(q, 'type_ancestors') and _has(q, 'types'): + out += [r[0] for r in q("""SELECT DISTINCT x.name FROM type_ancestors a JOIN types x ON x.id = a.ancestor_type_id + WHERE a.type_id = ? AND x.provenance <> 'client' ORDER BY x.name""", s['type_id'])] + if s.get('type_id') and _has(q, 'ext_type_base_unresolved'): + out += [r[0] for r in q("SELECT DISTINCT c3 FROM ext_type_base_unresolved WHERE c1 = ? AND c3 <> '' ORDER BY c3", s['type_id']) if r[0]] + if s.get('file') and s.get('line') and _has(q, 'type_refs'): + end = s.get('end_line') or s['line'] + nested = [(r[0], r[1] or r[0]) for r in q("""SELECT line, end_line FROM symbols WHERE file = ? AND method_id IS NULL + AND type_id IS NOT NULL AND line > ? AND line <= ?""", s['file'], s['line'], end)] + ph = ','.join('?' * len(BASE_CONTEXTS)) + for name, line in q(f"SELECT name, line FROM type_refs WHERE file = ? AND line BETWEEN ? AND ? AND context IN ({ph})", + s['file'], s['line'], end, *BASE_CONTEXTS): + if any(a <= line <= b for a, b in nested): continue + simple = re.split(r'[<(\[]', name or '')[0].split('.')[-1] + if not simple or simple in _NOT_A_BASE: continue + if _has(q, 'types') and q("SELECT 1 FROM types WHERE name = ? AND provenance = 'client' LIMIT 1", simple): continue + out.append(name) + simple = lambda b: re.split(r'[<(\[]', b or '')[0].split('.')[-1] + return [b for b in dict.fromkeys(out) if simple(b) and simple(b) not in MARKER_BASES and simple(b) not in _NOT_A_BASE] + + +# a marker interface declares no method, so nothing calls a method THROUGH it +MARKER_BASES = ('Serializable', 'Cloneable', 'RandomAccess', 'Remote', 'EventListener') + +# the order the reasons come in, strongest first. A surface that prints one prints the first. The call sites that write +# the name come BEFORE the two type-level reasons: those say only that a framework MAY enter the type (a `@Service` class, +# a class deriving from a library base whose methods the graph does not hold), and a `@Service` method its callers +# reach by name on an untyped receiver is called by them, not by the container. +NO_CALLER_KINDS = ('entry', 'test', 'registered', 'framework', 'overrides', 'by name', 'base', 'type decoration') + + +def _sym_row(q, i): + r = q("SELECT id, name, display, kind, file, line, end_line, owner, is_test, method_id, type_id FROM symbols WHERE id = ?", i) + if not r: return None + k = ('id', 'name', 'display', 'kind', 'file', 'line', 'end_line', 'owner', 'is_test', 'method_id', 'type_id') + return dict(zip(k, tuple(r[0]))) + + +def no_caller_reasons(q, mids): + """{method id: [(kind, label, evidence)]}: why nothing in the graph calls each of `mids`, strongest first, in the + order of NO_CALLER_KINDS. `evidence` is `file:line` where there is a line to read, else ''. + + entry the engine's entry_points reason (http, scheduled, framework_hook …) label: the reason + test a test method a runner calls label: 'test' + registered its own decoration registers it under a string (`@router.post("/x")`) label: '@X "key"' + framework a decoration on it that a framework reads, not a wrapper label: '@X' + overrides it overrides a method the graph does not contain (an override row to a + library method, or @Override with no base in the repository) label: the base method, or '' + by name call sites write its name on a receiver the engine could not type label: the count + base its type derives from a base the graph does not contain; whether it + overrides one of that base's methods cannot be told label: the base as written + type decoration a decoration a framework reads on its type label: '@X on Owner' + + A wrapper (INERT_DECORATIONS, or a decorator this repository declares) is never a reason and never hides the + by-name count. The type-level reasons (base, type decoration) are skipped for a private, static or constructor + member, which is never entered through its type. A method with no reason gets [].""" + out = {} + has = {t: _has(q, t) for t in ('entry_points', 'decorations', 'methods', 'overrides', 'unresolved_sites', 'call_sites')} + client_fn = {} + def in_repo_decorator(name): + """a decorator this repository declares as a function: it wraps, and the caller's name still reaches the body""" + if name not in client_fn: + client_fn[name] = bool(q("""SELECT 1 FROM symbols s JOIN methods m ON m.id = s.method_id + WHERE s.name = ? AND m.provenance = 'client' LIMIT 1""", name)) if has['methods'] else False + return client_fn[name] + for mid in dict.fromkeys(mids): + s = _sym_row(q, mid) + if not s: out[mid] = []; continue + rs = [] + if has['entry_points']: + rs += [('entry', r[0], '') for r in q("SELECT reason FROM entry_points WHERE method_id = ? ORDER BY reason", mid)] + if s.get('is_test') and not any(k == 'entry' and l == 'test' for k, l, _ in rs): rs.append(('test', 'test', '')) + loc = lambda f, l: f"{f}:{l}" if f and l else '' + decs = [(n, t, f, l) for n, t, f, l in q("SELECT name, text, file, line FROM decorations WHERE owner_id = ? ORDER BY line", mid)] \ + if has['decorations'] else [] + for n, t, f, l in decs: + sn = decoration_name(n) + if not sn or sn in INERT_DECORATIONS: continue + shown = '@' + ((t or '').split('(')[0].strip().lstrip('@[') or (n or '')).rstrip(']') + # the key a decoration registers it under, by the convention decoration_keys applies (none on a test) + keys = [] if s.get('is_test') else ax_registration.decoration_key_strings(t) + if keys: rs.append(('registered', f'{shown} "{keys[0]}"', loc(f, l))) + elif not in_repo_decorator(sn): rs.append(('framework', shown, loc(f, l))) + row = q("SELECT kind, visibility, owner_type_id FROM methods WHERE id = ?", mid) if has['methods'] else [] + mkind, vis, owner_tid = (tuple(row[0]) if row else ('', '', None)) + member_only = any(k in (mkind or '').upper() for k in ('STATIC', 'CONSTRUCTOR', 'INITIALIZER')) \ + or (vis or '').upper() == 'PRIVATE' or s.get('kind') == 'constructor' \ + or (not vis and re.match(r'_(?!_\w*__$)', s.get('name') or '') is not None) + if not member_only: + lib = [r[0] for r in q("""SELECT COALESCE(sb.display, b.qualified_name) FROM overrides o JOIN methods b ON b.id = o.method_id + LEFT JOIN symbols sb ON sb.method_id = b.id + WHERE o.overriding_method_id = ? AND b.provenance <> 'client'""", mid)] if has['overrides'] else [] + if lib: rs.append(('overrides', sorted(lib)[0], '')) + elif any(decoration_name(n) == 'Override' for n, *_ in decs) and not (has['overrides'] and q( + """SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id + WHERE o.overriding_method_id = ? AND b.provenance = 'client' LIMIT 1""", mid)): + rs.append(('overrides', '', '')) + own = None + if s.get('owner'): + r = q("SELECT id FROM symbols WHERE display = ? AND method_id IS NULL AND type_id IS NOT NULL LIMIT 1", s['owner']) + own = _sym_row(q, r[0][0]) if r else None + if own is None and owner_tid: + r = q("SELECT id FROM symbols WHERE type_id = ? AND method_id IS NULL LIMIT 1", owner_tid) + own = _sym_row(q, r[0][0]) if r else None + if own: + if not any(k == 'overrides' for k, *_ in rs): + for b in library_bases(q, own)[:2]: rs.append(('base', b, loc(own.get('file'), own.get('line')))) + if has['decorations']: + for n, t, f, l in q("SELECT name, text, file, line FROM decorations WHERE owner_id = ? ORDER BY line", own['id']): + sn = decoration_name(n) + if not sn or sn in INERT_DECORATIONS or in_repo_decorator(sn): continue + shown = '@' + ((t or '').split('(')[0].strip().lstrip('@[') or (n or '')).rstrip(']') + rs.append(('type decoration', f"{shown} on {own['display']}", loc(f, l))) + if s.get('name') and has['unresolved_sites'] and has['call_sites']: + # joined on the caller too: unresolved_sites is keyed (caller_id, call_site_id), and on the site alone the join + # scanned the whole table for every method (3.5 s for 10 methods on a 200 MB graph) + n = q("""SELECT count(*) FROM call_sites cs JOIN unresolved_sites u ON u.caller_id = cs.caller_id AND u.call_site_id = cs.id + WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""", s['name'])[0][0] + if n: rs.append(('by name', str(n), '')) + order = {k: i for i, k in enumerate(NO_CALLER_KINDS)} + out[mid] = sorted(dict.fromkeys(rs), key=lambda r: order[r[0]]) # stable within a kind: source order + return out + + +def no_caller_label(kind, label): + """one reason as the hooks print it after `←`: `entry (http)`, `? framework (@Scheduled)`, `0 resolved, 3 by name`""" + if kind in ('entry', 'test'): return f"entry ({label})" + if kind == 'by name': return f"0 resolved, {label} by name" + if kind == 'registered': return f"? framework ({label})" + if kind == 'overrides': return f"? framework (overrides {label or 'a library method'})" + if kind == 'base': return f"? framework (extends {label})" + return f"? framework ({label})" + + +def no_caller_phrase(kind, label): + """one reason as a sentence fragment, for impact's `next:` and --delete and path's note""" + if kind == 'entry': return ax_edges.entry_phrase(label) + (' (a framework or runner calls it)' if label != 'test' else '') + if kind == 'test': return 'a test (a runner calls it)' + if kind == 'registered': return f'registered by {label}: whoever writes that key reaches it, no call site does' + if kind == 'framework': return f'decorated {label}, which a framework reads: it may call it with no call site here' + if kind == 'overrides': return (f'it overrides {label}' if label else 'it overrides a library method') + ', so that library calls it' + if kind == 'base': return f'its type extends {label}, which the graph does not contain: if it overrides one of its methods, that library calls it' + if kind == 'type decoration': return f'{label}: a framework may enter it through its type' + if kind == 'by name': return f'{label} call site(s) write its name on a receiver the engine could not type (0 resolved)' + return label + + def _bean_call(q, ids, sites): """The container-bean layer on `calls it`, and the only rules in `direct` that a bundle without a container never exercises — which is why jackson (0 rows in ext_bean_def) was clean on it and keycloak (213) was not. @@ -900,11 +1158,14 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None, via=None # the lowest. Leaving the order to the table printed a different site (254 against 257) for the same caller. # a site inside a mock's stub or verification carries the tier "stub", exactly as the rules' `calls` fact does stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) - raw = [(c, m, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, m, t, f, l in q( + # a chained route link's edge sits on the line of its own verb and path, as in the `calls` fact + link_line = ax_registration.route_site_lines(q, rel) + raw = [(c, m, ax_edges.STUB_TIER if sid in stubs else t, f, link_line.get(sid, l)) for sid, c, m, t, f, l in q( f"""SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id=e.call_site_id WHERE e.callee_method_id IN ({ph}) AND e.callee_provenance='client' ORDER BY s.start_line""", *ids)] + if link_line: raw.sort(key=lambda r: r[4] or 0) sites = [(c, t, f, l) for c, _m, t, f, l in raw] bean_callers, why_of = _bean_call(q, ids, sites) # a call written against a base this declaration is override-equivalent to (#1542): its row replaces the plain one diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 77f053dd6..a54bbf524 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -83,7 +83,7 @@ does not restrict. Detail: `reference/context.md`. `src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) only when page 1's strongest rows are not enough. It finds config +(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. ## changed · test-impact — from an edit diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 67e77124f..f6bd5da72 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -77,11 +77,16 @@ line and names the constructor query to run; take that suggestion before acting builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. +- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from + the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a + test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission + check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its + name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the - base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by + base class)`, and `[resolved]` only when nothing else can run there; the Read and Grep hooks count the same callers); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md index 79e272fce..81d854c0f 100644 --- a/skills/axiomcode/reference/path.md +++ b/skills/axiomcode/reference/path.md @@ -87,7 +87,11 @@ have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. The conventions come from the + verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the + graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the + graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is + not one), a library method it overrides, the call sites that write its name, a library base of its type, a + decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the one module both tools read (`scripts/ax_registration.py`). - **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks diff --git a/tests/README.md b/tests/README.md index 58fe4e775..60245dc69 100644 --- a/tests/README.md +++ b/tests/README.md @@ -90,6 +90,9 @@ One check needs no graph and is its own script: package README, else named as collected by nothing; each runner's config read as that runner reads it (vitest projects/exclude/vite.config, jest rootDir/ testRegex/--config, playwright, node --test), each shape with a control (#1570; no engine) + python3 tests/script_tests.py a script-style test (a test-tree file run as a program, no framework) is selected by + test-impact with the command its project runs it by, beside a framework test that keeps + its own; a helper and a runner's setup file are not (indexes two cases, needs the engine) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/cases/java/cross-process-route/case.json b/tests/cases/java/cross-process-route/case.json new file mode 100644 index 000000000..65ebd15ee --- /dev/null +++ b/tests/cases/java/cross-process-route/case.json @@ -0,0 +1,22 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "REGRESSION GUARD (#1293): a handler served under a route is depended on by the client that sends to it, as a remote hop naming its transport and destination", + "run": ["impact", "ItemController.replace"], + "want": ["[remote] ItemClient.update", "(http) at /api/items/{id}"]}, + {"why": "CONTROL: the GET handler on the same route is reached by no POST client", + "run": ["impact", "ItemController.get"], + "avoid": ["ItemClient.update", "[remote]"]}, + {"why": "path says the two ends are connected across a process, and by what", + "run": ["path", "ItemClient.update", "ItemController.replace"], + "want": ["connected across a process: ItemClient.update → ItemController.replace", "[http] at /api/items/{id} (exact)"], + "expect_error": true}, + {"why": "changed --impact on the handler's body names the client on the other side of the route", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/app/ItemController.java", "--impact"], + "want": ["body ItemController.replace", "[remote] ItemClient.update"]}, + {"why": "test-impact crosses the route: the test that drives the client is selected on the remote rung", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/app/ItemController.java", "--why"], + "want": ["ItemClientTest", "[remote]"]}, + {"why": "context lists the other end of a route as its own tier, never as a call hop", + "run": ["context", "what handles replacing an item"], + "want": ["across a process boundary", "[remote] http"]} + ]} diff --git a/tests/cases/java/cross-process-route/new.java b/tests/cases/java/cross-process-route/new.java new file mode 100644 index 000000000..70159c423 --- /dev/null +++ b/tests/cases/java/cross-process-route/new.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id.trim(); + } +} diff --git a/tests/cases/java/cross-process-route/old.java b/tests/cases/java/cross-process-route/old.java new file mode 100644 index 000000000..91feccb24 --- /dev/null +++ b/tests/cases/java/cross-process-route/old.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id; + } +} diff --git a/tests/cases/java/cross-process-route/src/app/ItemClient.java b/tests/cases/java/cross-process-route/src/app/ItemClient.java new file mode 100644 index 000000000..b64f8e51f --- /dev/null +++ b/tests/cases/java/cross-process-route/src/app/ItemClient.java @@ -0,0 +1,13 @@ +package app; + +import org.springframework.web.client.RestTemplate; + +public class ItemClient { + + private final RestTemplate restTemplate = new RestTemplate(); + + /** a POST to the route: reaches replace(), never get() */ + public String update(String id) { + return restTemplate.postForObject("http://items/api/items/{id}", id, String.class); + } +} diff --git a/tests/cases/java/cross-process-route/src/app/ItemController.java b/tests/cases/java/cross-process-route/src/app/ItemController.java new file mode 100644 index 000000000..91feccb24 --- /dev/null +++ b/tests/cases/java/cross-process-route/src/app/ItemController.java @@ -0,0 +1,21 @@ +package app; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/items") +public class ItemController { + + @GetMapping("/{id}") + public String get(String id) { + return id; + } + + @PostMapping("/{id}") + public String replace(String id) { + return id; + } +} diff --git a/tests/cases/java/cross-process-route/src/test/ItemClientTest.java b/tests/cases/java/cross-process-route/src/test/ItemClientTest.java new file mode 100644 index 000000000..4b8b3dc49 --- /dev/null +++ b/tests/cases/java/cross-process-route/src/test/ItemClientTest.java @@ -0,0 +1,12 @@ +package test; + +import app.ItemClient; +import org.junit.jupiter.api.Test; + +public class ItemClientTest { + + @Test + void updates() { + new ItemClient().update("7"); + } +} diff --git a/tests/cases/java/impact-answer-in-pages/case.json b/tests/cases/java/impact-answer-in-pages/case.json index bb3354221..29c6ecd57 100644 --- a/tests/cases/java/impact-answer-in-pages/case.json +++ b/tests/cases/java/impact-answer-in-pages/case.json @@ -28,7 +28,7 @@ "want": [ "page 1 of", "more page(s) left", - "page=2 (MCP) or --page 2 (CLI)", + "ask for the next with --page 2", "--page all" ] }, diff --git a/tests/cases/javascript/chained-route-verbs/case.json b/tests/cases/javascript/chained-route-verbs/case.json new file mode 100644 index 000000000..6d0850b51 --- /dev/null +++ b/tests/cases/javascript/chained-route-verbs/case.json @@ -0,0 +1,37 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "each link of a chained registration `router.get(p1, a).get(p2, b).post(p3, c)` spans its receiver, so every link used to be read with the first link's verb and path: newPost said GET \"/posts\" at the chain's first line", + "run": ["impact", "newPost", "--kind", "method"], + "want": ["registered as a GET route \"/posts/new\"", "src/posts.js:15"], + "avoid": ["GET route \"/posts\" "]}, + {"why": "the last link of the chain is a POST, written on its own line", + "run": ["impact", "createPost", "--kind", "method"], + "want": ["registered as a POST route \"/posts\"", "src/posts.js:16"], + "avoid": ["GET route"]}, + {"why": "CONTROL: the chain's first link said its own verb and path before and still does", + "run": ["impact", "listPosts", "--kind", "method"], + "want": ["registered as a GET route \"/posts\"", "src/posts.js:14"], + "avoid": ["POST route", "/posts/new"]}, + {"why": "`router.route(p)` written over several lines: its verbs have no path of their own, so each keeps route()'s path under its own verb, on its own line, and route() itself is never the label", + "run": ["impact", "showPost", "--kind", "method"], + "want": ["registered as a GET route \"/posts/:id\"", "src/posts.js:18"], + "avoid": ["ROUTE route"]}, + {"why": "…and the chain's other verb", + "run": ["impact", "updatePost", "--kind", "method"], + "want": ["registered as a PUT route \"/posts/:id\"", "src/posts.js:19"], + "avoid": ["ROUTE route"]}, + {"why": "CONTROL: a plain, unchained registration is unchanged", + "run": ["impact", "listTags", "--kind", "method"], + "want": ["registered as a GET route \"/tags\"", "src/posts.js:20"]}, + {"why": "CONTROL: a chain on ONE line cannot be split by line, so its line names every verb on it with route()'s path, as it did before", + "run": ["impact", "renameTag", "--kind", "method"], + "want": ["registered as a GET/PUT route \"/tags/:id\"", "src/posts.js:21"], + "avoid": ["ROUTE route"]}, + {"why": "`path` names the line a direct caller makes the call on: a chained link's own line, not the chain's first", + "run": ["path", "*", "createPost"], + "want": ["calls it at :16"], + "avoid": ["calls it at :14"]}, + {"why": "CONTROL: `path` for the chain's first link still names the first line", + "run": ["path", "*", "listPosts"], + "want": ["calls it at :14"]} + ]} diff --git a/tests/cases/javascript/chained-route-verbs/src/posts.js b/tests/cases/javascript/chained-route-verbs/src/posts.js new file mode 100644 index 000000000..154cf29e7 --- /dev/null +++ b/tests/cases/javascript/chained-route-verbs/src/posts.js @@ -0,0 +1,23 @@ +'use strict'; +const express = require('express'); + +function listPosts(req, res) { res.json([]); } +function newPost(req, res) { res.send('form'); } +function createPost(req, res) { res.status(201).end(); } +function showPost(req, res) { res.json({}); } +function updatePost(req, res) { res.end(); } +function listTags(req, res) { res.json([]); } +function showTag(req, res) { res.json({}); } +function renameTag(req, res) { res.end(); } + +const router = express.Router(); +router.get('/posts', listPosts) + .get('/posts/new', newPost) + .post('/posts', createPost); +router.route('/posts/:id') + .get(showPost) + .put(updatePost); +router.get('/tags', listTags); +router.route('/tags/:id').get(showTag).put(renameTag); + +module.exports = router; diff --git a/tests/cases/javascript/class-expression-takes-its-binding/case.json b/tests/cases/javascript/class-expression-takes-its-binding/case.json new file mode 100644 index 000000000..293e7bd25 --- /dev/null +++ b/tests/cases/javascript/class-expression-takes-its-binding/case.json @@ -0,0 +1,48 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a class expression in a static field is that field's nested class: its method displayed as `Outer..ping`", + "run": ["impact", "lib.js:4"], + "want": ["change: Outer.Inner.ping (at lib.js:4)"], + "avoid": [""] + }, + { + "why": "the nested class is asked for by the name it displays as, and is one declaration, not a class and a field", + "run": ["impact", "Outer.Inner"], + "want": ["change: class Outer.Inner"], + "avoid": ["", "declared as"] + }, + { + "why": "a class expression bound to a variable takes the variable's name, and the variable is not a second declaration of it", + "run": ["impact", "Plain.go"], + "want": ["change: Plain.go"], + "avoid": [""] + }, + { + "why": "`impact Plain` answers for the class, not a const and a class of one name", + "run": ["impact", "Plain"], + "want": ["change: class Plain"], + "avoid": ["const Plain", "declared as"] + }, + { + "why": "a class expression assigned to a property is named by the property, owned by the receiver as written", + "run": ["impact", "lib.js:20"], + "want": ["change: registry.Widget.render (at lib.js:20)"], + "avoid": [""] + }, + { + "why": "CONTROL: a named class expression keeps its own name; the variable it initialises stays a variable", + "run": ["impact", "lib.js:27"], + "want": ["change: Real.hi (at lib.js:27)"], + "avoid": ["Alias.hi"] + }, + { + "why": "CONTROL: a function expression bound to a variable is still named by it", + "run": ["impact", "helper"], + "want": ["change: helper"], + "avoid": [""] + } + ] +} diff --git a/tests/cases/javascript/class-expression-takes-its-binding/lib.js b/tests/cases/javascript/class-expression-takes-its-binding/lib.js new file mode 100644 index 000000000..ab2681d52 --- /dev/null +++ b/tests/cases/javascript/class-expression-takes-its-binding/lib.js @@ -0,0 +1,37 @@ +// a class expression in a static field: its members are the nested class's +class Outer { + static Inner = class { + ping() { + return 1; + } + }; +} + +// a class expression bound to a variable +const Plain = class { + go() { + return 2; + } +}; + +// a class expression assigned to a property of an object +const registry = {}; +registry.Widget = class { + render() { + return 3; + } +}; + +// CONTROL: a named class expression keeps its own name +const Alias = class Real { + hi() { + return 4; + } +}; + +// CONTROL: a function expression bound to a variable is still named by it +const helper = function () { + return 5; +}; + +module.exports = { Outer, Plain, registry, Alias, helper }; diff --git a/tests/cases/javascript/class-expression-takes-its-binding/use.js b/tests/cases/javascript/class-expression-takes-its-binding/use.js new file mode 100644 index 000000000..66e387b44 --- /dev/null +++ b/tests/cases/javascript/class-expression-takes-its-binding/use.js @@ -0,0 +1,11 @@ +const { Outer, Plain, registry, Alias, helper } = require('./lib'); + +function main() { + const i = new Outer.Inner(); + const p = new Plain(); + const w = new registry.Widget(); + const a = new Alias(); + return i.ping() + p.go() + w.render() + a.hi() + helper(); +} + +module.exports = { main }; diff --git a/tests/cases/javascript/field-owner-and-computed-key/case.json b/tests/cases/javascript/field-owner-and-computed-key/case.json new file mode 100644 index 000000000..abc1866a4 --- /dev/null +++ b/tests/cases/javascript/field-owner-and-computed-key/case.json @@ -0,0 +1,125 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a field assigned in a constructor is owned by its class: `impact Store.items` answered `field items`, the owner dropped, so the answer could not say which class's field it was", + "run": [ + "impact", + "Store.items", + "--kind", + "field" + ], + "want": [ + "change: field Store.items", + "[in scope] Store.insert" + ], + "avoid": [ + "change: field items", + "[in scope] Queue.push" + ] + }, + { + "why": "CONTROL: the same field name on another class is that class's, and only its own method writes it", + "run": [ + "impact", + "Queue.items", + "--kind", + "field" + ], + "want": [ + "change: field Queue.items", + "[in scope] Queue.push" + ], + "avoid": [ + "[in scope] Store.insert" + ] + }, + { + "why": "a class field is owned by its class", + "run": [ + "impact", + "Counter.count", + "--kind", + "field" + ], + "want": [ + "change: field Counter.count" + ], + "avoid": [ + "change: field count" + ] + }, + { + "why": "`this.x` in a constructor function is a field of that function's class", + "run": [ + "impact", + "Legacy.name", + "--kind", + "field" + ], + "want": [ + "change: field Legacy.name", + "Legacy.describe" + ], + "avoid": [ + "change: field name" + ] + }, + { + "why": "a member whose key is an expression is named by the key as written: `static [Symbol.hasInstance]` displayed as `Counter.` with an empty name", + "run": [ + "impact", + "lib.js:26" + ], + "want": [ + "Counter.[Symbol.hasInstance]" + ], + "avoid": [ + "Counter. ", + "Counter. " + ] + }, + { + "why": "and a second computed key on the same class is named by its own key", + "run": [ + "impact", + "lib.js:29" + ], + "want": [ + "change: Counter.[Symbol.iterator] (at lib.js:29)" + ], + "avoid": [ + "Counter. " + ] + }, + { + "why": "a computed key is asked for by name as displayed: its dots are part of ONE segment, so `Counter.[Symbol.iterator]` is not split into `[Symbol` and `iterator]`", + "run": [ + "impact", + "Counter.[Symbol.iterator]" + ], + "want": [ + "change: Counter.[Symbol.iterator] [method]" + ], + "avoid": [ + "Counter.[Symbol.iterator] matched", + "declarations)", + "hasInstance] [method]" + ] + }, + { + "why": "CONTROL: a module-level binding keeps no owner", + "run": [ + "impact", + "settings" + ], + "want": [ + "change: const settings" + ], + "avoid": [ + ".settings" + ] + } + ] +} diff --git a/tests/cases/javascript/field-owner-and-computed-key/lib.js b/tests/cases/javascript/field-owner-and-computed-key/lib.js new file mode 100644 index 000000000..80b288209 --- /dev/null +++ b/tests/cases/javascript/field-owner-and-computed-key/lib.js @@ -0,0 +1,46 @@ +// fields assigned in a constructor: each owned by its class +class Store { + constructor() { + this.items = []; + this.seq = 0; + } + insert(x) { + this.items.push(x); + return ++this.seq; + } +} + +// CONTROL: the same field name on another class stays that class's +class Queue { + constructor() { + this.items = []; + } + push(x) { + this.items.push(x); + } +} + +// a class field, and members whose key is an expression +class Counter { + count = 0; + static [Symbol.hasInstance](x) { + return typeof x.count === 'number'; + } + [Symbol.iterator]() { + return [this.count][Symbol.iterator](); + } +} + +// a constructor function: `this.x` inside it is a field of it +function Legacy(name) { + this.name = name; +} +Legacy.prototype.describe = function () { + return this.name; +}; + +// CONTROL: a module-level binding has no owner +const settings = { debug: false }; +let hits = 0; + +module.exports = { Store, Queue, Counter, Legacy, settings, hits }; diff --git a/tests/cases/javascript/field-owner-and-computed-key/use.js b/tests/cases/javascript/field-owner-and-computed-key/use.js new file mode 100644 index 000000000..e9e3bdbf1 --- /dev/null +++ b/tests/cases/javascript/field-owner-and-computed-key/use.js @@ -0,0 +1,14 @@ +const { Store, Queue, Counter, Legacy, settings } = require('./lib'); + +function run() { + const s = new Store(); + s.insert(1); + const q = new Queue(); + q.push(2); + const c = new Counter(); + for (const n of c) console.log(n, c instanceof Counter); + const l = new Legacy('a'); + return l.describe() + settings.debug; +} + +module.exports = { run }; diff --git a/tests/cases/javascript/fileline-line-outside-file/case.json b/tests/cases/javascript/fileline-line-outside-file/case.json new file mode 100644 index 000000000..2dae3ef39 --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/case.json @@ -0,0 +1,42 @@ +{"lang": "javascript", + "checks": [ + {"why": "a line past the end of the file is refused with the file's length, not answered as its top-level code (#1601)", + "run": ["impact", "src/services/userService.js:99"], + "expect_error": true, + "want": ["src/services/userService.js has 4 lines; line 99 is not in it"], + "avoid": [""]}, + {"why": "line 0 is refused too", + "run": ["impact", "src/services/userService.js:0"], + "expect_error": true, + "want": ["line 0 is not in src/services/userService.js"], + "avoid": [""]}, + {"why": "the same through path", + "run": ["path", "*", "src/services/userService.js:0"], + "expect_error": true, + "want": ["line 0 is not in src/services/userService.js"], + "avoid": ["0 method(s)"]}, + {"why": "the line just past the end, through path, with a partial undotted file name", + "run": ["path", "*", "services/userService.js:5"], + "expect_error": true, + "want": ["src/services/userService.js has 4 lines; line 5 is not in it"], + "avoid": ["0 method(s)"]}, + {"why": "a file with no newline at its end: the line after its last is refused", + "run": ["impact", "app.js:3"], + "expect_error": true, + "want": ["src/app.js has 2 lines; line 3 is not in it"]}, + {"why": "control: the last line of a file with no newline at its end is still its top-level code", + "run": ["impact", "src/app.js:2"], + "want": ["app. (at src/app.js:2)"], + "avoid": ["is not in it"]}, + {"why": "control: the last line, top-level code, still answers as the module", + "run": ["impact", "src/services/userService.js:4"], + "want": ["userService. (at src/services/userService.js:4)"], + "avoid": ["is not in it"]}, + {"why": "control: a line inside a function still answers for that function, with its caller", + "run": ["path", "*", "src/services/userService.js:2"], + "want": ["getUser"], + "avoid": ["is not in it"]}, + {"why": "control: a file the index does not hold is still refused as before", + "run": ["impact", "zzz/userService.js:99"], + "expect_error": true, + "want": ["no callable spans zzz/userService.js:99"]}]} diff --git a/tests/cases/javascript/fileline-line-outside-file/src/app.js b/tests/cases/javascript/fileline-line-outside-file/src/app.js new file mode 100644 index 000000000..d61a3b3a4 --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/app.js @@ -0,0 +1,2 @@ +const { getUser } = require('./services/userService'); +getUser(1); \ No newline at end of file diff --git a/tests/cases/javascript/fileline-line-outside-file/src/db.js b/tests/cases/javascript/fileline-line-outside-file/src/db.js new file mode 100644 index 000000000..e764e07df --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/db.js @@ -0,0 +1 @@ +exports.find = function (id) { return { id }; }; diff --git a/tests/cases/javascript/fileline-line-outside-file/src/services/userService.js b/tests/cases/javascript/fileline-line-outside-file/src/services/userService.js new file mode 100644 index 000000000..bcba4fe0f --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/services/userService.js @@ -0,0 +1,4 @@ +const db = require('../db'); +function getUser(id) { return db.find(id); } +module.exports = { getUser }; +// last line diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/case.json b/tests/cases/javascript/fileline-partial-path-in-scope/case.json new file mode 100644 index 000000000..e0085d4de --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/case.json @@ -0,0 +1,48 @@ +{"lang": "javascript", + "checks": [ + {"why": "a basename matching five indexed files, one of them under --in, answers for that one and names it (#1666)", + "run": ["impact", "index.js:2", "--in", "packages/a"], + "want": ["change: runA (at packages/a/src/index.js:2)", "startA"], + "avoid": ["matches 5 indexed files", "startB", "runB"]}, + {"why": "the same through path, from everything into the one file under --in", + "run": ["path", "*", "index.js:2", "--in", "packages/b"], + "want": ["startB"], + "avoid": ["matches 5 indexed files", "startA"]}, + {"why": "a dir/file suffix is narrowed the same way", + "run": ["impact", "src/index.js:2", "--in", "packages/b"], + "want": ["change: runB (at packages/b/src/index.js:2)", "startB"], + "avoid": ["matches 2 indexed files", "startA"]}, + {"why": "control: a scope that still leaves two files is refused, listing only those two", + "run": ["impact", "index.js:2", "--in", "packages/c"], + "expect_error": true, + "want": ["matches 2 indexed files under *packages/c*", "packages/c/index.js:2", "packages/c/lib/index.js:2"], + "avoid": ["packages/a/src/index.js", "change: load"]}, + {"why": "control: the same through path", + "run": ["path", "*", "index.js:2", "--in", "packages/c"], + "expect_error": true, + "want": ["matches 2 indexed files under *packages/c*"], + "avoid": ["packages/d/index.js"]}, + {"why": "control: a scope holding none of the files is refused with every file listed", + "run": ["impact", "index.js:2", "--in", "shared"], + "expect_error": true, + "want": ["matches 5 indexed files", "none of them under *shared*", "packages/a/src/index.js:2", "packages/d/index.js:2"]}, + {"why": "control: without --in the basename is refused as before (#1599)", + "run": ["impact", "index.js:2"], + "expect_error": true, + "want": ["matches 5 indexed files", "packages/a/src/index.js:2", "packages/c/lib/index.js:2"]}, + {"why": "control: the full path with --in answers for it, labelled as typed", + "run": ["impact", "packages/a/src/index.js:2", "--in", "packages/a"], + "want": ["change: runA (at packages/a/src/index.js:2)", "startA"], + "avoid": ["startB"]}, + {"why": "a const on the line of a partial path matching two files is refused, not answered for one of them", + "run": ["impact", "config.js:1"], + "expect_error": true, + "want": ["matches 2 indexed files", "packages/a/src/config.js:1", "packages/b/src/config.js:1"], + "avoid": ["change: variable tokenA", "change: const tokenA"]}, + {"why": "the same const line with --in answers for the one file under the scope", + "run": ["impact", "config.js:1", "--in", "packages/b"], + "want": ["tokenB"], + "avoid": ["tokenA", "matches 2 indexed files"]}, + {"why": "control: a partial path matching one file is labelled as typed, with or without --in", + "run": ["impact", "d/index.js:2", "--in", "packages"], + "want": ["change: main (at d/index.js:2)"]}]} diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/app.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/app.js new file mode 100644 index 000000000..df44eb3b9 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/app.js @@ -0,0 +1,7 @@ +const { runA } = require('./index'); + +function startA() { + return runA(1); +} + +module.exports = { startA }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/config.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/config.js new file mode 100644 index 000000000..77db3fb2b --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/config.js @@ -0,0 +1,2 @@ +const tokenA = 'a'; +module.exports = { tokenA }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/index.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/index.js new file mode 100644 index 000000000..535663afa --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/a/src/index.js @@ -0,0 +1,4 @@ +function runA(x) { + return String(x); +} +module.exports = { runA }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/app.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/app.js new file mode 100644 index 000000000..e3b3f5f9f --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/app.js @@ -0,0 +1,7 @@ +const { runB } = require('./index'); + +function startB() { + return runB('1'); +} + +module.exports = { startB }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/config.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/config.js new file mode 100644 index 000000000..d0abade26 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/config.js @@ -0,0 +1,2 @@ +const tokenB = 'b'; +module.exports = { tokenB }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/index.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/index.js new file mode 100644 index 000000000..04b42876d --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/b/src/index.js @@ -0,0 +1,4 @@ +function runB(x) { + return Number(x); +} +module.exports = { runB }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/index.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/index.js new file mode 100644 index 000000000..c6c4483ec --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/index.js @@ -0,0 +1,4 @@ +function load() { + return 1; +} +module.exports = { load }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/lib/index.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/lib/index.js new file mode 100644 index 000000000..a6bf9045a --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/c/lib/index.js @@ -0,0 +1,4 @@ +function loadLib() { + return 2; +} +module.exports = { loadLib }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/packages/d/index.js b/tests/cases/javascript/fileline-partial-path-in-scope/packages/d/index.js new file mode 100644 index 000000000..3addcf4e5 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/packages/d/index.js @@ -0,0 +1,4 @@ +function main() { + return 3; +} +module.exports = { main }; diff --git a/tests/cases/javascript/fileline-partial-path-in-scope/shared/util.js b/tests/cases/javascript/fileline-partial-path-in-scope/shared/util.js new file mode 100644 index 000000000..eaa593faa --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-in-scope/shared/util.js @@ -0,0 +1,4 @@ +function helper() { + return 4; +} +module.exports = { helper }; diff --git a/tests/cases/javascript/imported-live-variable-is-unknown/case.json b/tests/cases/javascript/imported-live-variable-is-unknown/case.json new file mode 100644 index 000000000..f6c5012c0 --- /dev/null +++ b/tests/cases/javascript/imported-live-variable-is-unknown/case.json @@ -0,0 +1,148 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a named import of an exported let a setter reassigns is a live binding: path says unknown and names the site (#1708)", + "run": [ + "path", + "fireImported", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireImported at src/main.js:5 — the callee is another module's exported variable, which that module reassigns" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the same variable through a namespace import (#1708)", + "run": [ + "path", + "fireNs", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireNs at src/main.js:6 — the callee is another module's exported variable, which that module reassigns" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "exported by an export specifier, not a declaration (#1708)", + "run": [ + "path", + "fireSpecifier", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireSpecifier at src/main.js:7 — the callee is another module's exported variable, which that module reassigns" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "renamed through a re-export (#1708)", + "run": [ + "path", + "fireRelayed", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireRelayed at src/main.js:8 — the callee is another module's exported variable, which that module reassigns" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "through a namespace of a barrel that re-exports it with export * (#1708)", + "run": [ + "path", + "fireBarrelNs", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireBarrelNs at src/main.js:9 — the callee is another module's exported variable, which that module reassigns" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the reverse question is the same connection (#1708)", + "run": [ + "path", + "alpha", + "fireImported" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "in fireImported" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: an exported let that nothing reassigns holds only its initial value", + "run": [ + "path", + "fireFixed", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: the same unreassigned export through a namespace", + "run": [ + "path", + "fireNsFixed", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: an exported const arrow resolves", + "run": [ + "path", + "fireReady", + "ready" + ], + "want": [ + "fireReady → ready: 1 of 1 target(s) reached" + ], + "avoid": [ + "connection is UNKNOWN, not absent" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/imported-live-variable-is-unknown/src/barrel.js b/tests/cases/javascript/imported-live-variable-is-unknown/src/barrel.js new file mode 100644 index 000000000..58cde756f --- /dev/null +++ b/tests/cases/javascript/imported-live-variable-is-unknown/src/barrel.js @@ -0,0 +1,2 @@ +export { handler as relayed } from './hub.js'; +export * from './hub.js'; diff --git a/tests/cases/javascript/imported-live-variable-is-unknown/src/hub.js b/tests/cases/javascript/imported-live-variable-is-unknown/src/hub.js new file mode 100644 index 000000000..d625e0668 --- /dev/null +++ b/tests/cases/javascript/imported-live-variable-is-unknown/src/hub.js @@ -0,0 +1,8 @@ +export function alpha() { return 'a'; } +export let handler = null; +export function setHandler(h) { handler = h; } +let hook = null; +export { hook }; +export function setHook(h) { hook = h; } +export let fixed = null; +export const ready = () => 'r'; diff --git a/tests/cases/javascript/imported-live-variable-is-unknown/src/main.js b/tests/cases/javascript/imported-live-variable-is-unknown/src/main.js new file mode 100644 index 000000000..608b417f2 --- /dev/null +++ b/tests/cases/javascript/imported-live-variable-is-unknown/src/main.js @@ -0,0 +1,13 @@ +import { handler, hook, fixed, ready } from './hub.js'; +import * as hub from './hub.js'; +import { relayed } from './barrel.js'; +import * as barrel from './barrel.js'; +export function fireImported() { return handler(); } +export function fireNs() { return hub.handler(); } +export function fireSpecifier() { return hook(); } +export function fireRelayed() { return relayed(); } +export function fireBarrelNs() { return barrel.handler(); } +// controls: an export nothing reassigns, and one holding a function +export function fireFixed() { return fixed(); } +export function fireReady() { return ready(); } +export function fireNsFixed() { return hub.fixed(); } diff --git a/tests/cases/javascript/imported-live-variable-is-unknown/src/package.json b/tests/cases/javascript/imported-live-variable-is-unknown/src/package.json new file mode 100644 index 000000000..4c1eeabfc --- /dev/null +++ b/tests/cases/javascript/imported-live-variable-is-unknown/src/package.json @@ -0,0 +1 @@ +{ "name": "imported-live-variable", "type": "module" } diff --git a/tests/cases/javascript/instance-member-callee-is-unknown/case.json b/tests/cases/javascript/instance-member-callee-is-unknown/case.json new file mode 100644 index 000000000..9b2689955 --- /dev/null +++ b/tests/cases/javascript/instance-member-callee-is-unknown/case.json @@ -0,0 +1,105 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "External.run calls this.hook(), stored on the instance by another function: path says unknown and names the site (#1709)", + "run": [ + "path", + "External.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`hook()` in External.run at src/index.js:4 — the callee is a function stored on the instance from outside the class", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Assigned.run calls this.onReady(), stored by Object.assign(this, opts): path says unknown and names the site (#1709)", + "run": [ + "path", + "Assigned.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`onReady()` in Assigned.run at src/index.js:8 — the callee is a function stored on the instance from outside the class", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: this.x = x in the constructor is a field the graph sees, so it keeps its field reading (#1675)", + "run": [ + "path", + "Stored.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`x()` in Stored.run at src/index.js:12 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: this.own() resolves to a declared method, so the answer stays independent", + "run": [ + "path", + "Declared.run", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a base the graph cannot see may declare emit, so the call is left to the by-name leads", + "run": [ + "path", + "Sub.run", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: this.hasOwnProperty() is inherited from Object.prototype, not a stored value", + "run": [ + "path", + "Proto.run", + "alpha" + ], + "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/instance-member-callee-is-unknown/src/index.js b/tests/cases/javascript/instance-member-callee-is-unknown/src/index.js new file mode 100644 index 000000000..e749e0504 --- /dev/null +++ b/tests/cases/javascript/instance-member-callee-is-unknown/src/index.js @@ -0,0 +1,23 @@ +// `this.x()` where no class declares `x`: the function is stored on the instance +// from outside, so the site is a value callee (#1709). +function alpha() { return 'a'; } +class External { run() { return this.hook(); } } +function wire(e, fn) { e.hook = fn; } +class Assigned { + constructor(opts) { Object.assign(this, opts); } + run() { return this.onReady(); } +} +class Stored { + constructor(x) { this.x = x; } + run() { return this.x(); } +} +// controls: a declared method resolves; a base the graph cannot see may declare it +class Declared { + own() { return 1; } + run() { return this.own(); } +} +const Base = require('some-unstaged-base'); +class Sub extends Base { run() { return this.emit('x'); } } +// control: an Object.prototype member is the platform's, not a stored value +class Proto { run(k) { return this.hasOwnProperty(k); } } +module.exports = { alpha, External, wire, Assigned, Stored, Declared, Sub, Proto }; diff --git a/tests/cases/javascript/private-class-field/case.json b/tests/cases/javascript/private-class-field/case.json new file mode 100644 index 000000000..0e4c5fe41 --- /dev/null +++ b/tests/cases/javascript/private-class-field/case.json @@ -0,0 +1,19 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a private class field is written with its # (Vault.#secret): read as a member separator, the lookup asked for 'Vault..secret' and found nothing, though the field is in the graph under that very name", + "run": ["impact", "Vault.#secret"], + "want": ["change: field Vault.#secret", "Vault.reveal"], + "avoid": ["nothing named", "[in scope] Ledger.peek", "[resolved] Ledger.peek"]}, + {"why": "the bare #secret names the private field too, in every class that declares one", + "run": ["impact", "#secret"], + "want": ["field Vault.#secret", "Vault.reveal", "Ledger.peek"], + "avoid": ["nothing named"]}, + {"why": "control: a private METHOD written with its # still resolves to the method, not to a field", + "run": ["impact", "Vault.#hide"], + "want": ["change: Vault.#hide", "Vault.describe"], + "avoid": ["field"]}, + {"why": "control: a public field is answered under its owner, and a sibling that does not touch it is not a reader", + "run": ["impact", "Vault.label"], + "want": ["change: field Vault.label", "Vault.#hide"], + "avoid": ["[in scope] Vault.reveal", "[resolved] Vault.reveal"]} + ]} diff --git a/tests/cases/javascript/private-class-field/src/vault.js b/tests/cases/javascript/private-class-field/src/vault.js new file mode 100644 index 000000000..e6b743716 --- /dev/null +++ b/tests/cases/javascript/private-class-field/src/vault.js @@ -0,0 +1,12 @@ +export class Vault { + #secret = 1; + label = 'vault'; + reveal() { return this.#secret; } + #hide() { return this.label; } + describe() { return this.#hide(); } +} + +export class Ledger { + #secret = 2; + peek() { return this.#secret; } +} diff --git a/tests/cases/javascript/script-runner-is-a-test/case.json b/tests/cases/javascript/script-runner-is-a-test/case.json new file mode 100644 index 000000000..3d1fe6ec1 --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/case.json @@ -0,0 +1,28 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a runner under the test tree that calls no test framework is a test: impact counts it, and names the package script that runs it", + "run": ["impact", "normalize", "--tests"], + "want": ["run.js (script: (cd src && npm run test))", "text.test.js"], + "avoid": ["helpers.js (script", "0 of"]}, + {"why": "the runner counts in the summary line: before, the walk reached it and the count said 0 of 0", + "run": ["impact", "normalize"], + "want": ["tests: 4 of 4 test method(s)"]}, + {"why": "CONTROL: a runner's setup file has top-level code that reaches the change, and is still not a script test", + "run": ["impact", "normalize", "--tests"], + "avoid": ["setup.js (script", "node src/test/setup.js"]}, + {"why": "a runner no package script names, guarded by `require.main === module`, is run by its interpreter, never by a guessed framework", + "run": ["impact", "shout", "--tests"], + "want": ["smoke.js (script: node src/test/smoke.js)", "text.test.js"], + "avoid": ["run.js"]}, + {"why": "CONTROL: an unguarded script nothing names still counts as a test, but no interpreter is guessed for it (#1570)", + "run": ["impact", "shout", "--tests"], + "want": ["loose.js (script: no command found"], + "avoid": ["node src/test/loose.js"]}, + {"why": "CONTROL: a helper imported by a framework test is not a script test, and the framework test keeps its selection", + "run": ["impact", "sample", "--tests"], + "want": ["text.test.js"], + "avoid": ["(script", "helpers.js ("]}, + {"why": "the script test carries its run command in --json, which test-impact builds its command from", + "run": ["impact", "normalize", "--tests", "--json"], + "stdout_json": true, + "want": ["\"script\": true", "\"run\": \"(cd src && npm run test)\""]}]} diff --git a/tests/cases/javascript/script-runner-is-a-test/src/lib/text.js b/tests/cases/javascript/script-runner-is-a-test/src/lib/text.js new file mode 100644 index 000000000..67a871afa --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/lib/text.js @@ -0,0 +1,9 @@ +function normalize(s) { + return String(s).trim().toLowerCase(); +} + +function shout(s) { + return normalize(s).toUpperCase() + '!'; +} + +module.exports = { normalize, shout }; diff --git a/tests/cases/javascript/script-runner-is-a-test/src/package.json b/tests/cases/javascript/script-runner-is-a-test/src/package.json new file mode 100644 index 000000000..7d4265336 --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/package.json @@ -0,0 +1,2 @@ +{"name": "script-runner", "version": "1.0.0", "scripts": {"test": "node test/run.js", "test:unit": "vitest run"}, + "devDependencies": {"vitest": "^1.6.0"}} diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/helpers.js b/tests/cases/javascript/script-runner-is-a-test/src/test/helpers.js new file mode 100644 index 000000000..1782884ff --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/helpers.js @@ -0,0 +1,7 @@ +const { normalize } = require('../lib/text'); + +function sample() { + return normalize(' hi '); +} + +module.exports = { sample }; diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/loose.js b/tests/cases/javascript/script-runner-is-a-test/src/test/loose.js new file mode 100644 index 000000000..d38cf39c6 --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/loose.js @@ -0,0 +1,3 @@ +const { shout } = require('../lib/text'); + +console.log(shout('loose')); diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/run.js b/tests/cases/javascript/script-runner-is-a-test/src/test/run.js new file mode 100644 index 000000000..5bf89b341 --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/run.js @@ -0,0 +1,10 @@ +const assert = require('assert'); +const { normalize } = require('../lib/text'); + +function runCase(input, want) { + assert.strictEqual(normalize(input), want); +} + +runCase(' Hello ', 'hello'); +runCase('ABC', 'abc'); +console.log('ok'); diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/setup.js b/tests/cases/javascript/script-runner-is-a-test/src/test/setup.js new file mode 100644 index 000000000..137e4e3a5 --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/setup.js @@ -0,0 +1,4 @@ +// loaded by the runner before the framework tests (a setup file), never run on its own +const { normalize } = require('../lib/text'); + +globalThis.normalized = normalize(' SETUP '); diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/smoke.js b/tests/cases/javascript/script-runner-is-a-test/src/test/smoke.js new file mode 100644 index 000000000..0b178851c --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/smoke.js @@ -0,0 +1,5 @@ +const { shout } = require('../lib/text'); + +if (require.main === module) { + console.log(shout('smoke')); +} diff --git a/tests/cases/javascript/script-runner-is-a-test/src/test/text.test.js b/tests/cases/javascript/script-runner-is-a-test/src/test/text.test.js new file mode 100644 index 000000000..3c0841bdf --- /dev/null +++ b/tests/cases/javascript/script-runner-is-a-test/src/test/text.test.js @@ -0,0 +1,8 @@ +const { shout } = require('../lib/text'); +const { sample } = require('./helpers'); + +describe('shout', () => { + it('adds a bang', () => { + expect(shout(sample())).toBe('HI!'); + }); +}); diff --git a/tests/cases/javascript/stored-field-callee-shapes/case.json b/tests/cases/javascript/stored-field-callee-shapes/case.json new file mode 100644 index 000000000..b17c9107f --- /dev/null +++ b/tests/cases/javascript/stored-field-callee-shapes/case.json @@ -0,0 +1,220 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "FieldNull.run calls a class field initialised to null and filled by a setter: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "FieldNull.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:7 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "CtorNull.run calls a field set to null in the constructor and filled by a setter: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "CtorNull.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:12 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Fallback.run calls a field holding a parameter or a fallback function: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "Fallback.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:16 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Static.run calls a static field a static setter fills: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "Static.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:20 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "StaticField.run calls a static class field initialised to null: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "StaticField.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:24 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Alias.run calls a field read through an alias of this: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "Alias.run", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:28 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Maker.make calls a constructor stored in a field: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "Maker.make", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:32 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "callsWrapped calls a module variable holding what a library returned for a project function: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "callsWrapped", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/shapes.js:39 — the callee is a module-level variable", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: a module variable holding what a library call returned for a string is a library function", + "run": [ + "path", + "logs", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a module variable holding a platform function is a platform call", + "run": [ + "path", + "tags", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: this.emit() on a class extending a library class is an inherited library method, not a field", + "run": [ + "path", + "Bus.go", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a method call on a project instance resolves", + "run": [ + "path", + "useOwn", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a field holding a project function resolves, so the chain is found", + "run": [ + "path", + "Known.run", + "alpha" + ], + "want": [ + "Known.run → alpha: 1 of 1 target(s) reached" + ], + "avoid": [ + "connection is UNKNOWN, not absent" + ] + } + ] +} diff --git a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js new file mode 100644 index 000000000..753496438 --- /dev/null +++ b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js @@ -0,0 +1,47 @@ +'use strict'; +const debug = require('debug'); +function alpha() { return 'a'; } +class FieldNull { + cb = null; + set(f) { this.cb = f; } + run() { return this.cb(); } +} +class CtorNull { + constructor() { this.cb = null; } + set(f) { this.cb = f; } + run() { return this.cb(); } +} +class Fallback { + constructor(cb) { this.cb = cb || function () {}; } + run() { return this.cb(); } +} +class Static { + static set(f) { this.cb = f; } + static run() { return this.cb(); } +} +class StaticField { + static cb = null; + static run() { return this.cb(); } +} +class Alias { + constructor(cb) { this.cb = cb; } + run() { const self = this; return self.cb(); } +} +class Maker { + constructor(C) { this.Ctor = C; } + make() { return new this.Ctor(); } +} +const log = debug('app'); +function logs() { log('x'); return 1; } +const tag = require('util').format; +function tags() { return tag('%s', 'x'); } +const wrapped = debug(alpha); +function callsWrapped() { return wrapped(); } +// controls: none of these is a value callee +const EventEmitter = require('events'); +class Bus extends EventEmitter { go() { return this.emit('x'); } } +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 }; diff --git a/tests/cases/javascript/stored-value-callee-is-unknown/case.json b/tests/cases/javascript/stored-value-callee-is-unknown/case.json new file mode 100644 index 000000000..f22bd8699 --- /dev/null +++ b/tests/cases/javascript/stored-value-callee-is-unknown/case.json @@ -0,0 +1,269 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "fire calls a module-level variable a setter assigns: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "fire", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:6 — the callee is a module-level variable", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "Emitter.emit calls a function stored on this in the constructor: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "Emitter.emit", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:9 — the callee is a function stored in a field", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "optsTypeof calls an options-object member it tests with typeof: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "optsTypeof", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:13 — the callee is a member of a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "optsAnd calls an options-object member guarded by &&: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "optsAnd", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:14 — the callee is a member of a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "optsIf calls an options-object member guarded by if: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "optsIf", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:15 — the callee is a member of a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "optsOptional calls an options-object member with ?.(): the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "optsOptional", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:16 — the callee is a member of a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "condInvoke calls a conditional expression: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "condInvoke", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:17 — the callee is computed by an expression", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "seqInvoke calls a sequence expression: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "seqInvoke", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:18 — the callee is computed by an expression", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "newFn calls a function built from a string: the callee can be any function stored or handed to it, so path says unknown and names the site (#1675)", + "run": [ + "path", + "newFn", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/stored.js:19 — the callee is code built at run time", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: this.own() resolves to a method of the class, not a stored function", + "run": [ + "path", + "Emitter.callsOwn", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a member call on a core-module import is a platform call", + "run": [ + "path", + "joins", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a member call on a string literal is a platform call", + "run": [ + "path", + "literalTrim", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a function that calls nothing", + "run": [ + "path", + "pure", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a bare member call on an untyped parameter reads the same as a library method (res.send, s.trim) and is not counted", + "run": [ + "path", + "optsPlain", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: res.status().send() on a handler's parameters is a library call, not a stored function", + "run": [ + "path", + "respond", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a module-level variable holding a function resolves, so the chain is found", + "run": [ + "path", + "viaHelper", + "alpha" + ], + "want": [ + "viaHelper → alpha: 1 of 1 target(s) reached" + ], + "avoid": [ + "connection is UNKNOWN, not absent" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/stored-value-callee-is-unknown/src/stored.js b/tests/cases/javascript/stored-value-callee-is-unknown/src/stored.js new file mode 100644 index 000000000..8bcc315c4 --- /dev/null +++ b/tests/cases/javascript/stored-value-callee-is-unknown/src/stored.js @@ -0,0 +1,28 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +let handler = null; +function setHandler(h) { handler = h; } +function fire() { return handler(); } +class Emitter { + constructor(cb) { this.cb = cb; } + emit() { return this.cb(); } + own() { return 1; } + callsOwn() { return this.own(); } +} +function optsTypeof(opts) { if (typeof opts.onReady === 'function') return opts.onReady(); return null; } +function optsAnd(opts) { return opts.onDone && opts.onDone(); } +function optsIf(opts) { if (opts.onEnd) { opts.onEnd(); } } +function optsOptional(opts) { return opts.onStart?.(); } +function condInvoke(a, b, c) { return (c ? a : b)(); } +function seqInvoke(cb) { return (0, cb)(); } +function newFn(s) { return new Function(s)(); } +const helper = function () { return alpha(); }; +function viaHelper() { return helper(); } +function joins() { return path.join('a', 'b'); } +function literalTrim() { return 'abc'.trim(); } +function pure() { return 1 + 2; } +function optsPlain(opts) { return opts.onReady(); } +function respond(req, res) { return res.status(200).send(req.query.q); } +module.exports = { alpha, setHandler, fire, Emitter, optsTypeof, optsAnd, optsIf, optsOptional, condInvoke, seqInvoke, newFn, + viaHelper, joins, literalTrim, pure, optsPlain, respond }; diff --git a/tests/cases/python/later-page-continues-the-rows/case.json b/tests/cases/python/later-page-continues-the-rows/case.json new file mode 100644 index 000000000..36ca69de1 --- /dev/null +++ b/tests/cases/python/later-page-continues-the-rows/case.json @@ -0,0 +1,37 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "page 1 is the default view: five callers from one file, and the rest counted with where to get them", + "run": ["impact", "rate"], + "want": ["[resolved] quote_5 quotes.py:17", "… +3 more in 1 file(s): quotes.py (3)", "--page 2 for the next rows"], + "avoid": ["quote_6 quotes.py:20", "page 1 of"]}, + {"why": "page 2 prints the rows page 1 left out, not page 1's view again: the three callers the count stood for, and no summary of them", + "run": ["impact", "rate", "--page", "2"], + "want": ["page 2 of 2:", "[resolved] quote_6 quotes.py:20", "[resolved] quote_7 quotes.py:23", "[resolved] quote_8 quotes.py:26", "this is the last page"], + "avoid": ["quote_1 quotes.py:5", "quote_5 quotes.py:17", "… +3 more", "page 2 does not exist"]}, + {"why": "a continued section's heading counts the rows under it on that page, so the numbers on the page add up to what it shows", + "run": ["impact", "rate", "--page", "2"], + "want": ["reads or uses it (continued; on this page: 3 row(s): 3 resolved)"], + "avoid": ["reads or uses it (8 callable(s): 8 resolved): (continued)"]}, + {"why": "--page all prints every row, with no count standing in for any", + "run": ["impact", "rate", "--page", "all"], + "want": ["quote_1 quotes.py:5", "quote_8 quotes.py:26"], + "avoid": ["… +3 more", "page 1 of"]}, + {"why": "a one-page answer asked for page 2 says there is no page 2 instead of printing page 1 again", + "run": ["impact", "lonely", "--page", "2"], + "want": ["page 2 does not exist: this answer has 1 page"], + "avoid": ["[resolved] only_caller"]}, + {"why": "control: the same one-page answer without --page is printed whole, unpaged", + "run": ["impact", "lonely"], + "want": ["[resolved] only_caller quotes.py:29"], + "avoid": ["page 1 of", "does not exist"]}, + {"why": "the collapsed [by name] rows of a TYPE come with a search for the type's name, never for a compiler-made name like or ", + "run": ["impact", "Rate"], + "want": ["+4 [by name]", "grep -rnw -e \"Rate\" ."], + "avoid": ["", "", "\"__init__\""]}, + {"why": "and page 2 of that answer lists those four rows, headed as low-certainty rows with their own count", + "run": ["impact", "Rate", "--page", "2"], + "want": ["reads or uses it — low-certainty rows (on this page: 4 row(s): 4 by name)", "[by name] show_1 views.py:4", "[by name] show_7 views.py:28"], + "avoid": ["+4 [by name]"]}, + {"why": "control: a method's collapsed [by name] calls keep the search for its call, `.value(`", + "run": ["impact", "Rate.value"], + "want": ["grep -rn \"\\.value(\" ."]}]} diff --git a/tests/cases/python/later-page-continues-the-rows/quotes.py b/tests/cases/python/later-page-continues-the-rows/quotes.py new file mode 100644 index 000000000..affb35a4e --- /dev/null +++ b/tests/cases/python/later-page-continues-the-rows/quotes.py @@ -0,0 +1,29 @@ +from rates import rate, lonely + + +def quote_1(): + return rate() + 1 + +def quote_2(): + return rate() + 2 + +def quote_3(): + return rate() + 3 + +def quote_4(): + return rate() + 4 + +def quote_5(): + return rate() + 5 + +def quote_6(): + return rate() + 6 + +def quote_7(): + return rate() + 7 + +def quote_8(): + return rate() + 8 + +def only_caller(): + return lonely() diff --git a/tests/cases/python/later-page-continues-the-rows/rates.py b/tests/cases/python/later-page-continues-the-rows/rates.py new file mode 100644 index 000000000..f28cc401b --- /dev/null +++ b/tests/cases/python/later-page-continues-the-rows/rates.py @@ -0,0 +1,11 @@ +class Rate: + def value(self): + return 1 + + +def rate(): + return Rate().value() + + +def lonely(): + return 2 diff --git a/tests/cases/python/later-page-continues-the-rows/views.py b/tests/cases/python/later-page-continues-the-rows/views.py new file mode 100644 index 000000000..0adcb47ac --- /dev/null +++ b/tests/cases/python/later-page-continues-the-rows/views.py @@ -0,0 +1,29 @@ +from rates import Rate + + +def show_1(r: Rate, x): + return x.value() + + +def show_2(r: Rate, x): + return x.value() + + +def show_3(r: Rate, x): + return x.value() + + +def show_4(r: Rate, x): + return x.value() + + +def show_5(r: Rate, x): + return x.value() + + +def show_6(r: Rate, x): + return x.value() + + +def show_7(r: Rate, x): + return x.value() diff --git a/tests/cases/python/script-runner-is-a-test/app/__init__.py b/tests/cases/python/script-runner-is-a-test/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/script-runner-is-a-test/app/pricing.py b/tests/cases/python/script-runner-is-a-test/app/pricing.py new file mode 100644 index 000000000..984987ebb --- /dev/null +++ b/tests/cases/python/script-runner-is-a-test/app/pricing.py @@ -0,0 +1,6 @@ +def price(amount): + return round(amount * 1.2, 2) + + +def discount(amount): + return round(price(amount) * 0.9, 2) diff --git a/tests/cases/python/script-runner-is-a-test/case.json b/tests/cases/python/script-runner-is-a-test/case.json new file mode 100644 index 000000000..9902e9c59 --- /dev/null +++ b/tests/cases/python/script-runner-is-a-test/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a runner under the test tree with its own main guard and no test framework is a test: impact counts it and names the interpreter that runs it", + "run": ["impact", "price", "--tests"], + "want": ["check_prices.py (script: python tests/check_prices.py)", "test_discount.py"], + "avoid": ["server.py (script", "0 of"]}, + {"why": "the same runner counts in the summary line, not only in the listing", + "run": ["impact", "price"], + "want": ["tests: 2 of 2 test method(s)"]}, + {"why": "CONTROL: a pytest test keeps its selection, and a runner that does not reach the change is not listed", + "run": ["impact", "discount", "--tests"], + "want": ["test_discount.py"], + "avoid": ["check_prices"]}, + {"why": "the script test carries its run command in --json, which test-impact builds its command from", + "run": ["impact", "price", "--tests", "--json"], + "stdout_json": true, + "want": ["\"script\": true", "\"run\": \"python tests/check_prices.py\""]}]} diff --git a/tests/cases/python/script-runner-is-a-test/tests/check_prices.py b/tests/cases/python/script-runner-is-a-test/tests/check_prices.py new file mode 100644 index 000000000..57a32d9e7 --- /dev/null +++ b/tests/cases/python/script-runner-is-a-test/tests/check_prices.py @@ -0,0 +1,10 @@ +from app.pricing import price + + +def main(): + assert price(10) == 12.0 + print("ok") + + +if __name__ == '__main__': + main() diff --git a/tests/cases/python/script-runner-is-a-test/tests/fixtures/server.py b/tests/cases/python/script-runner-is-a-test/tests/fixtures/server.py new file mode 100644 index 000000000..3476bbe25 --- /dev/null +++ b/tests/cases/python/script-runner-is-a-test/tests/fixtures/server.py @@ -0,0 +1,3 @@ +from app.pricing import price + +PRICE = price(1) diff --git a/tests/cases/python/script-runner-is-a-test/tests/test_discount.py b/tests/cases/python/script-runner-is-a-test/tests/test_discount.py new file mode 100644 index 000000000..9f5f042e9 --- /dev/null +++ b/tests/cases/python/script-runner-is-a-test/tests/test_discount.py @@ -0,0 +1,5 @@ +from app.pricing import discount + + +def test_discount(): + assert discount(10) == 10.8 diff --git a/tests/cases/python/tests-outside-src/app/__init__.py b/tests/cases/python/tests-outside-src/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/tests-outside-src/app/pricing.py b/tests/cases/python/tests-outside-src/app/pricing.py new file mode 100644 index 000000000..602ce49bc --- /dev/null +++ b/tests/cases/python/tests-outside-src/app/pricing.py @@ -0,0 +1,6 @@ +def price(qty): + return qty * 3 + + +def discount(total): + return total - 1 diff --git a/tests/cases/python/tests-outside-src/app/tests/__init__.py b/tests/cases/python/tests-outside-src/app/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/tests-outside-src/app/tests/test_discount.py b/tests/cases/python/tests-outside-src/app/tests/test_discount.py new file mode 100644 index 000000000..29bf74fb5 --- /dev/null +++ b/tests/cases/python/tests-outside-src/app/tests/test_discount.py @@ -0,0 +1,5 @@ +from app.pricing import discount + + +def test_discount(): + assert discount(5) == 4 diff --git a/tests/cases/python/tests-outside-src/case.json b/tests/cases/python/tests-outside-src/case.json new file mode 100644 index 000000000..2a90bd717 --- /dev/null +++ b/tests/cases/python/tests-outside-src/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": "app", + "checks": [ + {"why": "the index read only --src app, and the test that calls price sits in tests/ outside it: 0 tests is said to be unseen, with the file", + "run": ["impact", "price"], + "want": ["tests: 0 of", "NOT SEEN: the graph was indexed with --src app", "tests/test_price.py"]}, + {"why": "the same fact reaches --json, which test-impact reads", + "run": ["impact", "price", "--json"], + "stdout_json": true, + "want": ["\"tests_outside_src\"", "tests/test_price.py"]}, + {"why": "near miss: a change a test inside --src reaches is counted, and the outside tree is not raised", + "run": ["impact", "discount"], + "want": ["tests: 1 of"], + "avoid": ["NOT SEEN"]}]} diff --git a/tests/cases/python/tests-outside-src/tests/test_price.py b/tests/cases/python/tests-outside-src/tests/test_price.py new file mode 100644 index 000000000..23f2d9652 --- /dev/null +++ b/tests/cases/python/tests-outside-src/tests/test_price.py @@ -0,0 +1,5 @@ +from app.pricing import price + + +def test_price(): + assert price(2) == 6 diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md b/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md new file mode 100644 index 000000000..c0006293d --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/NOTES.md @@ -0,0 +1 @@ +Set WIDGET_SECRET_KEY before starting; a missing widget prints "widget went missing". diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/case.json b/tests/cases/python/text-when-the-graph-has-no-answer/case.json new file mode 100644 index 000000000..0d1a97502 --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/case.json @@ -0,0 +1,37 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a name no graph declares (an environment variable) is answered with the lines that write it, each labelled [text], the one in code placed in its declaration and the others marked not indexed", + "run": ["impact", "WIDGET_SECRET_KEY"], "expect_error": true, + "want": ["nothing named 'WIDGET_SECRET_KEY'", "[text] the graph has no declaration for 'WIDGET_SECRET_KEY'", "[text] deploy.yml:2 not indexed", "[text] src/widgets.py:5 in load_settings", "[text] NOTES.md:1", "not call edges"], + "avoid": ["[resolved]", "reads or uses it"]}, + {"why": "a name written in no source file is searched all the same: here only the case file itself holds it, and says so as text", + "run": ["path", "noSuchWidgetThing", "*"], "expect_error": true, + "want": ["no declaration in the graph contains 'noSuchWidgetThing'", "[text] case.json:"], + "avoid": ["[text] src/", "[text] NOTES.md"]}, + {"why": "path with an undeclared endpoint gets the same [text] rows", + "run": ["path", "handler", "WIDGET_SECRET_KEY"], "expect_error": true, + "want": ["[text] src/widgets.py:5 in load_settings"]}, + {"why": "a message the task quotes is searched as text and placed in the declaration that raises it", + "run": ["context", "why does it raise \"widget went missing\" for a known id"], + "want": ["[text] no declaration is named 'widget went missing'", "[text] src/widgets.py:10 in find_widget", "[text] NOTES.md:1"]}, + {"why": "a quoted string asked of impact is answered from its literal sites and then from the lines that write it", + "run": ["impact", "\"widget went missing\""], + "want": ["[string]", "[text] 'widget went missing' is a string", "[text] NOTES.md:1 not indexed"]}, + {"why": "an --in that no graph holds is dropped for the repository root and said once, not refused with a menu", + "run": ["context", "where is the widget found", "--in", "srcc/widgts"], + "want": ["scope: no indexed file in any graph has 'srcc/widgts' in its path — answering at the repository root instead", "find_widget"], + "avoid": ["re-run with one of these"]}, + {"why": "impact under an --in no graph holds answers at the root and says so, instead of an empty answer", + "run": ["impact", "find_widget", "--in", "srcc"], + "want": ["scope: no indexed file in any graph has 'srcc'", "[resolved] handler"]}, + {"why": "control: a name the graph resolves answers from the graph only, with no [text] block", + "run": ["impact", "find_widget"], + "want": ["[resolved] handler"], + "avoid": ["[text] the graph has no declaration", "not call edges"]}, + {"why": "control: a task that quotes a name the graph declares leaves it to the graph", + "run": ["context", "how does 'find_widget' look a widget up"], + "avoid": ["no declaration is named"]}, + {"why": "control: a real --in narrows as before and prints no scope line", + "run": ["impact", "find_widget", "--in", "src"], + "want": ["[resolved] handler"], + "avoid": ["scope: no indexed file"]}]} diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml b/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml new file mode 100644 index 000000000..67b766f28 --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/deploy.yml @@ -0,0 +1,2 @@ +env: + WIDGET_SECRET_KEY: change-me diff --git a/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py b/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py new file mode 100644 index 000000000..574f4ec5f --- /dev/null +++ b/tests/cases/python/text-when-the-graph-has-no-answer/src/widgets.py @@ -0,0 +1,16 @@ +import os + + +def load_settings(): + return {'key': os.environ.get('WIDGET_SECRET_KEY', '')} + + +def find_widget(store, wid): + if wid not in store: + raise LookupError("widget went missing") + return store[wid] + + +def handler(store, wid): + load_settings() + return find_widget(store, wid) diff --git a/tests/cases/python/why-no-caller-one-reader/case.json b/tests/cases/python/why-no-caller-one-reader/case.json new file mode 100644 index 000000000..83cc9cedc --- /dev/null +++ b/tests/cases/python/why-no-caller-one-reader/case.json @@ -0,0 +1,28 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a caching wrapper is not why nothing calls it: impact names the call sites that write its name, not the decoration", + "run": ["impact", "get_standings"], + "want": ["why nothing in the graph calls get_standings: 1 call site(s) write its name on a receiver the engine could not type"], + "avoid": ["@cache.memoize"]}, + {"why": "path's empty upstream gives the same reason impact gives", + "run": ["path", "*", "get_standings"], "expect_error": true, + "want": ["why nothing in the graph calls get_standings: 1 call site(s) write its name"], + "avoid": ["@cache.memoize"]}, + {"why": "control: a function a decorator the repository declares wraps has that wrapper as its caller, so no reason is printed", + "run": ["impact", "refresh_board"], + "want": ["[resolved] wrapper src/app/decorators.py:7 — calls it"], + "avoid": ["why nothing in the graph calls", "decorated @admins_only"]}, + {"why": "near-miss control: a library decoration a framework reads, with no call site writing the name, is the reason on impact and path alike", + "run": ["impact", "load_user"], + "want": ["decorated @app_hooks.before_request, which a framework reads"]}, + {"why": "control: the same reason on path", + "run": ["path", "*", "load_user"], "expect_error": true, + "want": ["why nothing in the graph calls load_user: decorated @app_hooks.before_request"]}, + {"why": "a method of a class deriving from a base the graph does not contain names that base; a description string is not a registration key", + "run": ["impact", "Board.get"], + "want": ["its type extends Resource, which the graph does not contain"], + "avoid": ["registered by @boards.doc", "\"Endpoint to list the boards\": whoever"]}, + {"why": "control: a private helper nothing calls has no reason, and the change is local", + "run": ["impact", "_unused_helper"], + "want": ["the change is local"], + "avoid": ["why nothing in the graph calls"]}]} diff --git a/tests/cases/python/why-no-caller-one-reader/src/app/__init__.py b/tests/cases/python/why-no-caller-one-reader/src/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/why-no-caller-one-reader/src/app/decorators.py b/tests/cases/python/why-no-caller-one-reader/src/app/decorators.py new file mode 100644 index 000000000..3eb77bb0d --- /dev/null +++ b/tests/cases/python/why-no-caller-one-reader/src/app/decorators.py @@ -0,0 +1,8 @@ +import functools + + +def admins_only(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper diff --git a/tests/cases/python/why-no-caller-one-reader/src/app/scores.py b/tests/cases/python/why-no-caller-one-reader/src/app/scores.py new file mode 100644 index 000000000..c1be0b959 --- /dev/null +++ b/tests/cases/python/why-no-caller-one-reader/src/app/scores.py @@ -0,0 +1,32 @@ +from flask_caching import Cache +from flask_restx import Namespace, Resource +from app.decorators import admins_only +from app.hooks import app_hooks + +cache = Cache() +boards = Namespace("boards") + + +@cache.memoize(timeout=60) +def get_standings(count=None): + return [] + + +@admins_only +def refresh_board(): + return 1 + + +@app_hooks.before_request +def load_user(): + return None + + +def _unused_helper(): + return 2 + + +class Board(Resource): + @boards.doc("Endpoint to list the boards") + def get(self): + return [] diff --git a/tests/cases/python/why-no-caller-one-reader/src/app/views.py b/tests/cases/python/why-no-caller-one-reader/src/app/views.py new file mode 100644 index 000000000..cad33a56d --- /dev/null +++ b/tests/cases/python/why-no-caller-one-reader/src/app/views.py @@ -0,0 +1,6 @@ +def show(scores_mod): + return scores_mod.get_standings() + + +def poke(board): + return board.refresh_board() diff --git a/tests/cases/typescript/cross-process-route/case.json b/tests/cases/typescript/cross-process-route/case.json new file mode 100644 index 000000000..3f643a462 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/case.json @@ -0,0 +1,24 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a handler served under a route is depended on by the client that sends to that route: the edge is a remote hop naming its transport and destination, not a call (#1293)", + "run": ["impact", "createOrder"], + "want": ["[remote] placeOrder", "across a process boundary (http) at /api/orders"], + "avoid": ["fetchOrder"]}, + {"why": "CONTROL: the same route under GET is served by another handler, and a POST client must not land on it", + "run": ["impact", "listOrders"], + "want": ["tests: 0 of 1"], + "avoid": ["placeOrder", "[remote]"]}, + {"why": "path says the two ends are connected across a process, and by what, when no call joins them", + "run": ["path", "placeOrder", "createOrder"], + "want": ["connected across a process: placeOrder → createOrder", "[http] at /api/orders (exact)"], + "expect_error": true}, + {"why": "changed --impact on the handler's body names the client on the other side of the route", + "run": ["changed", "{repo}", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "src/server/orders.ts", "--impact"], + "want": ["body createOrder", "[remote] placeOrder", "(http) at /api/orders"]}, + {"why": "test-impact crosses the route: the test that drives the client is selected, on the remote rung, not as a call chain", + "run": ["test-impact", "{repo}", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "src/server/orders.ts", "--why"], + "want": ["src/client/client.test.ts", "[remote]"]}, + {"why": "context lists the other end of a route as its own tier, with transport and destination, never as a call hop", + "run": ["context", "what handles creating an order"], + "want": ["across a process boundary", "[remote] http /api/orders (exact) placeOrder → createOrder"]} + ]} diff --git a/tests/cases/typescript/cross-process-route/new.ts b/tests/cases/typescript/cross-process-route/new.ts new file mode 100644 index 000000000..0fa301915 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/new.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return ['created', 'now'].join(' '); +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/old.ts b/tests/cases/typescript/cross-process-route/old.ts new file mode 100644 index 000000000..7413eb5d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/old.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return 'created'; +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/src/client/axios.d.ts b/tests/cases/typescript/cross-process-route/src/client/axios.d.ts new file mode 100644 index 000000000..6bef28b59 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/axios.d.ts @@ -0,0 +1,8 @@ +declare module 'axios' { + interface HttpStatic { + get(url: string, config?: unknown): Promise; + post(url: string, data?: unknown, config?: unknown): Promise; + } + const axios: HttpStatic; + export default axios; +} diff --git a/tests/cases/typescript/cross-process-route/src/client/client.test.ts b/tests/cases/typescript/cross-process-route/src/client/client.test.ts new file mode 100644 index 000000000..b024863b7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/client.test.ts @@ -0,0 +1,6 @@ +import { it, expect } from 'vitest'; +import { placeOrder } from './client'; + +it('places an order', async () => { + expect(await placeOrder({ sku: 'a' })).toBeDefined(); +}); diff --git a/tests/cases/typescript/cross-process-route/src/client/client.ts b/tests/cases/typescript/cross-process-route/src/client/client.ts new file mode 100644 index 000000000..5e9d947b7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/client/client.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; +import { ORDERS } from '../shared/paths'; + +// POST /api/orders: reaches createOrder across the process, and never listOrders (GET) +export async function placeOrder(body: unknown) { return axios.post(ORDERS, body); } + +// GET /api/orders/{}: reaches getOrder +export async function fetchOrder(id: string) { return fetch(`/api/orders/${id}`); } diff --git a/tests/cases/typescript/cross-process-route/src/server/orders.ts b/tests/cases/typescript/cross-process-route/src/server/orders.ts new file mode 100644 index 000000000..7413eb5d2 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/server/orders.ts @@ -0,0 +1,22 @@ +import { ORDERS } from '../shared/paths'; + +declare const app: { + get(path: string, h: unknown): void; + post(path: string, h: unknown): void; +}; + +export function listOrders(): string { + return 'all'; +} + +export function createOrder(): string { + return 'created'; +} + +export function getOrder(): string { + return 'one'; +} + +app.get(ORDERS, listOrders); +app.post(ORDERS, createOrder); +app.get('/api/orders/:id', getOrder); diff --git a/tests/cases/typescript/cross-process-route/src/shared/paths.ts b/tests/cases/typescript/cross-process-route/src/shared/paths.ts new file mode 100644 index 000000000..b6b7246f7 --- /dev/null +++ b/tests/cases/typescript/cross-process-route/src/shared/paths.ts @@ -0,0 +1 @@ +export const ORDERS = '/api/orders'; diff --git a/tests/cases/typescript/private-class-field/case.json b/tests/cases/typescript/private-class-field/case.json new file mode 100644 index 000000000..69c43f14a --- /dev/null +++ b/tests/cases/typescript/private-class-field/case.json @@ -0,0 +1,19 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a private class field is written with its # (Vault.#secret): read as a member separator, the lookup asked for 'Vault..secret' and found nothing, though the field is in the graph under that very name", + "run": ["impact", "Vault.#secret"], + "want": ["change: field Vault.#secret", "Vault.reveal"], + "avoid": ["nothing named", "[in scope] Ledger.peek", "[resolved] Ledger.peek"]}, + {"why": "the bare #secret names the private field too, in every class that declares one", + "run": ["impact", "#secret"], + "want": ["field Vault.#secret", "Vault.reveal", "Ledger.peek"], + "avoid": ["nothing named"]}, + {"why": "control: a private METHOD written with its # still resolves to the method, not to a field", + "run": ["impact", "Vault.#hide"], + "want": ["change: Vault.#hide", "Vault.describe"], + "avoid": ["field"]}, + {"why": "control: a public field is answered under its owner, and a sibling that does not touch it is not a reader", + "run": ["impact", "Vault.label"], + "want": ["change: field Vault.label", "Vault.#hide"], + "avoid": ["[in scope] Vault.reveal", "[resolved] Vault.reveal"]} + ]} diff --git a/tests/cases/typescript/private-class-field/src/vault.ts b/tests/cases/typescript/private-class-field/src/vault.ts new file mode 100644 index 000000000..40f78ed33 --- /dev/null +++ b/tests/cases/typescript/private-class-field/src/vault.ts @@ -0,0 +1,12 @@ +export class Vault { + #secret: number = 1; + label: string = 'vault'; + reveal(): number { return this.#secret; } + #hide() { return this.label; } + describe() { return this.#hide(); } +} + +export class Ledger { + #secret: number = 2; + peek(): number { return this.#secret; } +} diff --git a/tests/cases/typescript/scope-is-a-path-prefix/case.json b/tests/cases/typescript/scope-is-a-path-prefix/case.json new file mode 100644 index 000000000..216c902de --- /dev/null +++ b/tests/cases/typescript/scope-is-a-path-prefix/case.json @@ -0,0 +1,29 @@ +{ + "lang": "typescript", + "checks": [ + { + "why": "a scope that names a directory of the repository is that directory: --in tools is tools/, not src/devtools/ whose name contains it (#1584)", + "run": ["context", "seed the rows", "--in", "tools"], + "want": ["tools/seed.ts"], + "avoid": ["src/devtools/seed.ts"] + }, + { + "why": "impact --in answers for the declaration under the scope, not a same-named one in a directory whose name contains it (#1584)", + "run": ["impact", "seedRows", "--in", "tools"], + "want": ["tools/seed.ts"], + "avoid": ["src/devtools/seed.ts", "startApp"] + }, + { + "why": "control: a scope that names no path of the repository still matches anywhere in a path, as it always did", + "run": ["impact", "seedRows", "--in", "devtools"], + "want": ["src/devtools/seed.ts"], + "avoid": [" tools/seed.ts"] + }, + { + "why": "control: a parent directory holds everything below it", + "run": ["context", "seed the rows", "--in", "src"], + "want": ["src/devtools/seed.ts"], + "avoid": [" tools/seed.ts"] + } + ] +} diff --git a/tests/cases/typescript/scope-is-a-path-prefix/src/app.ts b/tests/cases/typescript/scope-is-a-path-prefix/src/app.ts new file mode 100644 index 000000000..9d9c13557 --- /dev/null +++ b/tests/cases/typescript/scope-is-a-path-prefix/src/app.ts @@ -0,0 +1,5 @@ +import { seedRows } from './devtools/seed' + +export function startApp(): number { + return seedRows().length +} diff --git a/tests/cases/typescript/scope-is-a-path-prefix/src/devtools/seed.ts b/tests/cases/typescript/scope-is-a-path-prefix/src/devtools/seed.ts new file mode 100644 index 000000000..1d324469b --- /dev/null +++ b/tests/cases/typescript/scope-is-a-path-prefix/src/devtools/seed.ts @@ -0,0 +1,3 @@ +export function seedRows(): string[] { + return ['row'] +} diff --git a/tests/cases/typescript/scope-is-a-path-prefix/tools/seed.ts b/tests/cases/typescript/scope-is-a-path-prefix/tools/seed.ts new file mode 100644 index 000000000..89269d802 --- /dev/null +++ b/tests/cases/typescript/scope-is-a-path-prefix/tools/seed.ts @@ -0,0 +1,7 @@ +function fillRows(n: number): number[] { + return Array.from({ length: n }, (_, i) => i) +} + +export function seedRows(n: number): number[] { + return fillRows(n) +} diff --git a/tests/cases/typescript/scope-spanning-two-roots/case.json b/tests/cases/typescript/scope-spanning-two-roots/case.json index 953b780ad..97652efa0 100644 --- a/tests/cases/typescript/scope-spanning-two-roots/case.json +++ b/tests/cases/typescript/scope-spanning-two-roots/case.json @@ -70,7 +70,7 @@ ] }, { - "why": "a misspelled root names ITSELF rather than failing the whole call behind a good one", + "why": "a misspelled root names ITSELF and the root it is close to, and the call is answered under the good one rather than failing behind it", "run": [ "context", "stop formatting the amount on the subtotal", @@ -80,9 +80,14 @@ "packages/ordrs" ], "want": [ - "no indexed file has 'packages/ordrs' in its path" + "no indexed file in any graph has 'packages/ordrs' in its path — answering under packages/core instead", + "close: packages/orders", + "packages/core/src/format.ts" ], - "expect_error": true + "avoid": [ + "packages/orders/src/invoice.ts", + "re-run with one of these" + ] } ] } diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example b/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example new file mode 100644 index 000000000..df1b10cee --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/.env.example @@ -0,0 +1 @@ +ORDER_BATCH_LIMIT=25 diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json new file mode 100644 index 000000000..7bd327e6f --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/case.json @@ -0,0 +1,14 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an environment variable no declaration names, matched only by name, also gets the lines that write it, as [text], placed in its function", + "run": ["impact", "ORDER_BATCH_LIMIT"], "expect_error": true, + "want": ["nothing named 'ORDER_BATCH_LIMIT' is declared", "[by name] readLimit", + "[text] the graph has no declaration for 'ORDER_BATCH_LIMIT'", "[text] src/orders.ts:2 in readLimit", "[text] .env.example:1 not indexed", "not call edges"], + "avoid": ["[resolved]"]}, + {"why": "a quoted error message in a task is found as text in the function that throws it", + "run": ["context", "placing an order fails with 'order exceeds the batch limit'"], + "want": ["[text] no declaration is named 'order exceeds the batch limit'", "[text] src/orders.ts:7 in placeOrder"]}, + {"why": "control: a declared function answers from the graph alone", + "run": ["impact", "readLimit"], + "want": ["placeOrder"], + "avoid": ["[text] the graph has no declaration"]}]} diff --git a/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts b/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts new file mode 100644 index 000000000..0202c0599 --- /dev/null +++ b/tests/cases/typescript/text-when-the-graph-has-no-answer/src/orders.ts @@ -0,0 +1,10 @@ +export function readLimit(): number { + return Number(process.env.ORDER_BATCH_LIMIT ?? '10'); +} + +export function placeOrder(qty: number): number { + if (qty > readLimit()) { + throw new Error("order exceeds the batch limit"); + } + return qty; +} diff --git a/tests/cases/typescript/undeclared-name-is-not-a-type/case.json b/tests/cases/typescript/undeclared-name-is-not-a-type/case.json new file mode 100644 index 000000000..88abe5741 --- /dev/null +++ b/tests/cases/typescript/undeclared-name-is-not-a-type/case.json @@ -0,0 +1,37 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a local variable no declaration carries is not a type: `path '*' labelOf` answered `type labelOf (not declared here …)` with its mentions as exact callers and a passing verified: line (#1602)", + "run": ["path", "*", "labelOf", "--depth", "1"], + "expect_error": true, + "want": ["no declaration in the graph contains 'labelOf'", "[by name] readLibPrefixes", "a variable named labelOf"], + "avoid": ["type labelOf", "not declared here", "Through exact calls only", "verified:"]}, + {"why": "the same word on the other side is not a type that 'calls nothing itself' either (#1602)", + "run": ["path", "changed", "*"], + "expect_error": true, + "want": ["no declaration in the graph contains 'changed'", "[by name] readLibPrefixes"], + "avoid": ["type changed", "not declared here", "the other direction has it"]}, + {"why": "impact on a local's name says nothing is declared and names the mentions as leads, not a phantom type (#1602)", + "run": ["impact", "labelOf"], + "expect_error": true, + "want": ["nothing named 'labelOf' is declared", "[by name] readLibPrefixes"], + "avoid": ["type labelOf", "not declared here"]}, + {"why": "a member written after a dot (`Codes.OK`) is not a type in scope, however it is spelt (#1602)", + "run": ["path", "*", "OK"], + "expect_error": true, + "want": ["[by name] isOk"], + "avoid": ["type OK", "not declared here"]}, + {"why": "an object-literal key (`{ Bucket: … }`) is not a type in scope either (#1602)", + "run": ["path", "*", "Bucket"], + "expect_error": true, + "want": ["[by name] upload"], + "avoid": ["type Bucket", "not declared here"]}, + {"why": "CONTROL: a class the code calls statically without declaring (`Registry.find`) is still a type", + "run": ["path", "*", "Registry"], + "want": ["type Registry (not declared here", "lookup"]}, + {"why": "CONTROL: a type the code constructs without declaring is still one, and who constructs it is still found", + "run": ["path", "*", "Remote"], + "want": ["type Remote (not declared here", "load", "main"]}, + {"why": "CONTROL: a declared function is unchanged", + "run": ["path", "*", "readLibPrefixes"], + "want": ["everything that can reach readLibPrefixes", "main"], + "avoid": ["not declared here"]}]} diff --git a/tests/cases/typescript/undeclared-name-is-not-a-type/src/build.ts b/tests/cases/typescript/undeclared-name-is-not-a-type/src/build.ts new file mode 100644 index 000000000..f27053a23 --- /dev/null +++ b/tests/cases/typescript/undeclared-name-is-not-a-type/src/build.ts @@ -0,0 +1,29 @@ +export function readLibPrefixes(files: string[]): string[] { + const labelOf = new Map(); + let changed = false; + for (const f of files) { + if (!labelOf.has(f)) { labelOf.set(f, f.toUpperCase()); changed = true; } + } + return changed ? files.map((f) => labelOf.get(f) ?? f) : files; +} + +export function load(url: string) { + return new Remote(url); +} + +export function isOk(code: number) { + return code === Codes.OK; +} + +export function lookup(key: string) { + return Registry.find(key); +} + +export function upload(send: (p: object) => void, body: string) { + send({ Bucket: "b", Body: body }); +} + +export function main() { + readLibPrefixes(["a", "b"]); + load("x"); +} diff --git a/tests/changed_range.py b/tests/changed_range.py index 5866985a8..622236f67 100644 --- a/tests/changed_range.py +++ b/tests/changed_range.py @@ -12,7 +12,8 @@ control a function added to an existing file keeps its own line a copy without git a refusal naming what to pass, not invented "added" declarations control the same copy with a named file: every declaration in it counts, and its tests are named - a changed fixture named as outside the index, with the test file that names it + a changed fixture named as outside the index, with the test file that names it; the command holds only + the graph's language's test modules, and another language's tests are counted, not listed control a data file no test names: said so, never "no change" Each run builds a real graph in a throwaway git repository (Python, so the rules compile once and are cached). @@ -36,6 +37,10 @@ " for c in os.listdir(os.path.join(HERE, 'cases')):\n" " json.load(open(os.path.join(HERE, 'cases', c, 'case.json')))\n"), 'tests/cases/one/case.json': '{"q": 1}\n', + # other files of the test tree that name the fixture: another language's test and a Python helper no runner collects + 'tests/cases/CaseLoader.java': 'class CaseLoader { String f = "cases/one/case.json"; }\n', + 'tests/cases/CaseLoader.cs': 'class CaseLoaderCs { string f = "one/case.json"; }\n', + 'tests/cases/helper.py': 'CASE = "one/case.json"\n', 'data/lookup.csv': 'a,1\n', } # a new module whose docstrings hold words a line-by-line reading took for methods (`works (…)`, `side(effect)`) @@ -115,6 +120,14 @@ def ax(repo, *a): check('test_stock' not in out, "test-impact --range does not select the tests of upstream's commit", out) check('tests/test_newmod.py' in out, 'a new test file is itself a test to run', out) check("named as 'case.json' by: tests/test_cases.py" in out, 'the test that loads the changed fixture is named', out) + run = [l for l in out.split('\n') if 'with the tests above:' in l] + check(len(run) == 1 and 'pytest ' in run[0] and 'tests/test_cases.py' in run[0], 'the text tier adds its Python test to the pytest line', out) + check(run and not any(x in run[0] for x in ('.java', '.cs', 'helper.py')), + "the pytest line holds no other language's file and no module pytest does not collect", out) + check('CaseLoader.java' not in out and 'CaseLoader.cs' not in out and "another language name them too" in out, + "another language's test files are counted, not listed: that language's graph answers for them", out) + check(any(l.startswith('next: run pytest') and '.java' not in l and '.cs' not in l for l in out.split('\n')), + 'next: runs the Python tests only', out) check('page 1 of' not in out, 'no page footer', out) # control: a range whose base is an ancestor keeps its answer and gets no note diff --git a/tests/corrupt_graph.py b/tests/corrupt_graph.py new file mode 100644 index 000000000..b999e00d1 --- /dev/null +++ b/tests/corrupt_graph.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""tests/corrupt_graph.py — a graph.sqlite that is not a readable database is said, moved aside and rebuilt, never read. + +A disk that filled mid-write, a copy cut short or a crash leaves graph.sqlite malformed, and every verb, from the shell +and through the MCP server, died in a Python traceback (`sqlite3.DatabaseError: file is not a database`, `database disk +image is malformed`). Each check lays out a throwaway git repository with a real graph (so it needs the engine, as +tests/refresh_races.py), damages the graph one way, and asks what a user would ask next. + + garbage graph.sqlite is random bytes: `impact` says the graph is corrupt, rebuilds it and answers; the + rebuilt graph reads cleanly and the marker is gone. No traceback. + truncated graph.sqlite cut short, asked without a build (as the hooks ask): the last good graph (the kept + baseline) answers and the answer says so. With no such graph the verb stops in words, exit 2. + bad page the schema reads but a page a query needs does not: the error mid-answer is checked with + quick_check, said in words, and the graph moved aside for a rebuild. No traceback. + up to date a build over unchanged files with the corrupt marker rebuilds instead of saying "up to date". + other language a corrupt graph of another language (.axiomcode/lang/) is said, and the main language + still answers. +Controls: a sound graph answers exactly as before with no word of corruption and no marker; an EMPTY graph.sqlite is +not called corrupt (it is a graph never indexed); a build over unchanged files without the marker is still "up to date". + + python3 tests/corrupt_graph.py [-v] +""" +import json, os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +BUILD = os.path.join(SCRIPTS, 'axiomcode-build') +IMPACT = os.path.join(SCRIPTS, 'axiomcode-impact') + +UTIL = ('export function helper(): number {\n return 1\n}\n\n' + 'export function caller(): number {\n return helper()\n}\n\n' + 'export function other(): number {\n return helper()\n}\n') +TSCONFIG = '{ "compilerOptions": { "strict": true }, "include": ["src"] }\n' + + +def sh(cwd, *cmd, env=None, timeout=900): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env, timeout=timeout) + + +def repo_with(work, name, files): + repo = os.path.join(work, name) + for rel, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(repo, rel)), exist_ok=True) + open(os.path.join(repo, rel), 'w').write(text) + sh(repo, 'git', 'init', '-q'); sh(repo, 'git', 'add', '-A') + sh(repo, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base') + return repo + + +def tiers(db): + import sqlite3 + c = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + try: return dict(c.execute("SELECT tier, count(*) FROM call_edges GROUP BY tier").fetchall()) + finally: c.close() + + +def main(argv): + verbose = '-v' in argv + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok or not (detail or '').strip() else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + hook = {k: v for k, v in env.items() if k not in ('AXIOMCODE_AUTOBUILD', 'AXIOMCODE_BUILD_NOWAIT')} # as the hooks ask + work = tempfile.mkdtemp(prefix='axiomcode-corrupt-') + try: + repo = repo_with(work, 'app', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL}) + out = os.path.join(repo, '.axiomcode', 'out'); ptr = os.path.join(out, 'graph.sqlite') + b = sh(repo, AX, 'index', '.', env=env) + check(b.returncode == 0, 'the graph builds', b.stdout + b.stderr) + good = os.path.realpath(ptr); saved = os.path.join(work, 'good.sqlite'); shutil.copyfile(good, saved) + t0 = tiers(good) + + # ── control: a sound graph ────────────────────────────────────────────────────────────────────────── + r0 = sh(repo, AX, 'impact', 'helper', '.', env=env) + check(r0.returncode == 0 and 'caller' in r0.stdout, 'control: a sound graph answers', r0.stdout + r0.stderr) + check('corrupt' not in (r0.stdout + r0.stderr) and not os.path.exists(os.path.join(out, 'corrupt')), + 'control: a sound graph is never called corrupt', r0.stderr) + u = sh(repo, 'bash', BUILD, repo, env=env) + check('up to date' in u.stdout, 'control: a build over unchanged files without the marker is still "up to date"', u.stdout + u.stderr) + + # ── garbage ───────────────────────────────────────────────────────────────────────────────────────── + open(good, 'wb').write(os.urandom(8192)) + t = time.time(); r = sh(repo, AX, 'impact', 'helper', '.', env=env); took = time.time() - t + both = r.stdout + r.stderr + check('Traceback' not in both, 'garbage: no traceback', both) + check('is corrupt' in r.stderr, 'garbage: the verb says the graph is corrupt', both) + check(r.returncode == 0 and 'caller' in r.stdout, f'garbage: the graph is rebuilt and the verb answers ({took:.0f}s)', both) + check(os.path.exists(ptr) and tiers(os.path.realpath(ptr)) == t0, 'garbage: the rebuilt graph reads cleanly, with the same edges', str(t0)) + check(not os.path.exists(os.path.join(out, 'corrupt')), 'garbage: the corrupt marker goes with the rebuild') + + # ── truncated, with and without a last good graph ─────────────────────────────────────────────────── + good = os.path.realpath(ptr) + base = os.path.join(repo, '.axiomcode', 'base', 'out'); os.makedirs(base, exist_ok=True) + shutil.copyfile(saved, os.path.join(base, 'graph.sqlite')) # a baseline a refresh kept + data = open(good, 'rb').read(); open(good, 'wb').write(data[:len(data) // 3]) + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both, 'truncated: no traceback', both) + check(r.returncode == 0 and 'caller' in r.stdout and 'last good graph' in r.stderr, + 'truncated: the last good graph answers, and the answer says so', both) + check(os.path.exists(os.path.join(out, 'corrupt')), 'truncated: the repository is marked for a rebuild') + shutil.rmtree(os.path.join(repo, '.axiomcode', 'base')) + shutil.copyfile(saved, good); open(good, 'wb').write(data[:len(data) // 3]) + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both and r.returncode == 2 and 'corrupt' in both and 'axiomcode index' in both, + 'truncated: with no earlier graph the verb stops in words that name the fix', both) + + # ── up to date ────────────────────────────────────────────────────────────────────────────────────── + u = sh(repo, 'bash', BUILD, repo, env=env) + check(u.returncode == 0 and 'up to date' not in u.stdout and os.path.exists(ptr) and tiers(os.path.realpath(ptr)) == t0, + 'up to date: a build with the corrupt marker rebuilds instead of saying "up to date"', u.stdout + u.stderr) + check(not os.path.exists(os.path.join(out, 'corrupt')), 'up to date: the marker goes with the rebuild') + + # ── through the MCP server ────────────────────────────────────────────────────────────────────────── + # the server runs the verb without waiting for a build (AXIOMCODE_BUILD_NOWAIT) and hands back its words + open(os.path.realpath(ptr), 'wb').write(os.urandom(8192)) + sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp')); os.environ.update(AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + import server + text = server.run(['impact', 'helper', repo]) + check('Traceback' not in text and 'corrupt' in text, 'mcp: the tool answer says the graph is corrupt, no traceback', text) + sys.path.insert(0, SCRIPTS); import ax_fresh + for _ in range(600): # the rebuild it started + if os.path.exists(ptr) and not ax_fresh.building(repo): break + time.sleep(1) + text = server.run(['impact', 'helper', repo]) + check('caller' in text and 'corrupt' not in text, 'mcp: once rebuilt the tool answers again', text) + + # ── bad page ──────────────────────────────────────────────────────────────────────────────────────── + good = os.path.realpath(ptr) + data = bytearray(open(good, 'rb').read()); page = int.from_bytes(data[16:18], 'big') or 65536 + for p in range(max(2, len(data) // page // 2), len(data) // page): # the later pages: tables, not the schema + data[p * page:p * page + 8] = b'\xff' * 8 + open(good, 'wb').write(bytes(data)) + import sqlite3 + try: + sqlite3.connect(f'file:{good}?mode=ro', uri=True).execute("SELECT count(*) FROM sqlite_master").fetchone(); probe = True + except sqlite3.DatabaseError: probe = False + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both and 'corrupt' in both, f'bad page: the error {"mid-answer" if probe else "on open"} is said in words, not a traceback', both) + check(not os.path.exists(good) or os.path.exists(good + '.corrupt'), 'bad page: the graph is moved aside for a rebuild') + u = sh(repo, 'bash', BUILD, repo, env=env) + check(u.returncode == 0 and tiers(os.path.realpath(ptr)) == t0, 'bad page: the next build restores the graph', u.stdout + u.stderr) + + # ── control: an empty graph.sqlite is not corrupt ─────────────────────────────────────────────────── + sys.path.insert(0, SCRIPTS); import ax_fresh + empty = os.path.join(work, 'empty.sqlite'); open(empty, 'w').close() + check(ax_fresh.graph_corrupt(empty) == '', 'control: an empty graph.sqlite is not called corrupt (it was never indexed)') + check(ax_fresh.graph_corrupt(os.path.realpath(ptr)) == '' and ax_fresh.graph_corrupt(os.path.realpath(ptr), thorough=True) == '', + 'control: a sound graph passes the probe and quick_check') + + # ── other language ────────────────────────────────────────────────────────────────────────────────── + mixed = repo_with(work, 'mixed', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL, + 'pkg/__init__.py': '', 'pkg/m.py': 'def h():\n return 1\n\ndef g():\n return h()\n'}) + b = sh(mixed, AX, 'index', '.', '--lang', 'typescript,python', env=env) + ldb = os.path.join(mixed, '.axiomcode', 'lang', 'python', 'out', 'graph.sqlite') + check(b.returncode == 0 and os.path.isfile(ldb), 'other language: both graphs build', b.stdout + b.stderr) + if os.path.isfile(ldb): + open(ldb, 'wb').write(os.urandom(8192)) + r = sh(mixed, AX, 'impact', 'helper', '.', env=env) + both = r.stdout + r.stderr + check('Traceback' not in both and 'caller' in r.stdout, 'other language: the main language still answers, no traceback', both) + check('python graph' in both and 'corrupt' in both, 'other language: the corrupt python graph is named', both) + for _ in range(600): # its background rebuild + if os.path.isfile(ldb) and not ax_fresh.graph_corrupt(ldb) and not ax_fresh.building(mixed): break + time.sleep(1) + r = sh(mixed, AX, 'impact', 'h', '.', env=env) + check(r.returncode == 0 and 'g' in r.stdout and 'corrupt' not in r.stderr, 'other language: its graph is rebuilt and answers again', r.stdout + r.stderr) + finally: + if verbose: print('work dir:', work) + else: shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAILED' if fails else 'passed'}: {len(fails)} failure(s)") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index d723b263f..f6a49aaf5 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -42,12 +42,15 @@ ' public void prime(Object anything) {\n registry.lookup("jobs").nudgeAll();\n }\n}\n'), P + 'Jobs.java': ('package app.orders;\n\nimport org.springframework.scheduling.annotation.Scheduled;\n' 'import org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n' - '@RestController\npublic class Jobs implements Runnable {\n' + '@RestController\npublic class Jobs {\n' ' @GetMapping("/orders")\n public String listOrders() { return new Plain().countAll(); }\n\n' ' @Scheduled(fixedRate = 1000)\n public void sweepStale() { new Plain().countAll(); }\n\n' - ' @Override\n public void run() { new Plain().countAll(); }\n\n' ' public void nudgeAll() { new Plain().countAll(); }\n\n' ' public void pokeAll() { new Plain().countAll(); }\n}\n'), + # a library base: `run` overrides it, `tickAll` only may (the graph does not hold Runnable's methods) + P + 'Ticker.java': ('package app.orders;\n\npublic class Ticker implements Runnable {\n' + ' @Override\n public void run() { new Plain().countAll(); }\n\n' + ' public void tickAll() { new Plain().countAll(); }\n}\n'), P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), # two overloads in one file: one name to the reader, printed once @@ -110,13 +113,15 @@ def git(repo, *a): g = grep(repo, 'listOrders|sweepStale|nudgeAll|pokeAll|unusedCount') check('a route handler with no caller reads as an entry point, not as 0', '← entry (http)' in line_of(g, 'Jobs.listOrders'), g) check('a scheduled method reads as an entry point', '← entry (scheduled)' in line_of(g, 'Jobs.sweepStale'), g) - check('a method whose name is written at an untyped call site counts that site', '← 0 resolved, 1 by name' in line_of(g, 'Jobs.nudgeAll'), g) + check('a method whose name is written at an untyped call site counts that site, before the annotation on its class', + '← 0 resolved, 1 by name' in line_of(g, 'Jobs.nudgeAll'), g) check('a method of a framework-annotated class says which annotation', '← ? framework (@RestController on Jobs)' in line_of(g, 'Jobs.pokeAll'), g) check('control: an undecorated method nothing calls still reads 0', '← 0 →' in line_of(g, 'Plain.unusedCount'), g) - r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Jobs.java')}) + r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Ticker.java')}) check('a Read labels a library override the same way', 'run ←? framework (overrides a library method)' in r, r) + check('a method of a class with a library base that it may not override names the base', 'tickAll ←? framework (extends Runnable)' in r, r) check('control: a Read of a method with callers still counts them', - 'countAll ←5' in fire(repo, 'r2', 'Read', {'file_path': os.path.join(repo, P + 'Plain.java')})) + 'countAll ←6' in fire(repo, 'r2', 'Read', {'file_path': os.path.join(repo, P + 'Plain.java')})) # ── the preview order (#1507) and several declarations (#1546) ─────────────────────────────────────── g = grep(repo, 'findById') @@ -198,7 +203,8 @@ def edit(session, text, hook='enrich.py', event='PostToolUse'): tool = os.path.join(repo, 'tool.py') r = fire(repo, 'p1', 'Read', {'file_path': tool}) check('a whole-script read does not list its own top level as a caller', '' not in r, r) - check('control: the cross-file callee it cannot show is still there', 'helper ←0 →1' in r, r) + check('control: the cross-file callee it cannot show is still there', 'helper ← callers in range →1' in r, r) + check('a declaration whose only caller is in the lines read does not read as ←0', 'helper ←0' not in r, r) r = fire(repo, 'p2', 'Read', {'file_path': tool, 'offset': 8, 'limit': 3}) check('control: a range that leaves out the top-level call names it, at the line of the call', 'main L8' in r and 'tool. L17' in r, r) @@ -213,6 +219,38 @@ def edit(session, text, hook='enrich.py', event='PostToolUse'): ('+5 more' in more or '+4 more' in more) and '' not in more, r) check('control: the named ones left over are still named there', 'f' in more.split(':', 1)[-1], r) +# ── one reader for "why nothing calls it" (graph_sql.no_caller_reasons), and callers through an interface ────────── +CASES = os.path.join(ROOT, 'tests', 'cases') +with tempfile.TemporaryDirectory() as repo: + shutil.copytree(os.path.join(CASES, 'python', 'why-no-caller-one-reader', 'src'), os.path.join(repo, 'src')) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + subprocess.run(['bash', AX, 'index', repo, '--lang', 'python'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + check('the reader project indexes', False) + else: + g = grep(repo, 'get_standings|refresh_board|load_user|_unused_helper', session='g-why') + check('a caching wrapper is not the reason: the Grep line counts the call sites that write the name, as impact does', + '← 0 resolved, 1 by name' in line_of(g, 'get_standings') and 'memoize' not in g, g) + check('near-miss control: a library decoration a framework reads is still the reason', + '← ? framework (@app_hooks.before_request)' in line_of(g, 'load_user'), g) + check('control: a private helper with no reason still reads 0', '← 0 →' in line_of(g, '_unused_helper'), g) +with tempfile.TemporaryDirectory() as repo: + shutil.copytree(os.path.join(CASES, 'java', 'callers-through-the-interface', 'src'), os.path.join(repo, 'src')) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + subprocess.run(['bash', AX, 'index', repo, '--lang', 'java'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + check('the interface project indexes', False) + else: + rd = lambda f, sid: fire(repo, sid, 'Read', {'file_path': os.path.join(repo, 'src', 'app', *f.split('/'))}) + r = rd('store/Store.java', 'r-iface') + check('an interface method whose callers hold an interface-typed field lists them, as impact does (#1542)', + 'Store.save L4 ← CtorIface.place, FieldIface.place' in r, r) + r = rd('widgets/WidgetService.java', 'r-iface2') + check('a caller through the interface is listed beside a resolved one', 'WidgetReport.line' in r and 'WidgetController.get' in r, r) + r = rd('shapes/Circle.java', 'r-area') + check('near-miss control: a caller typed on the other implementation is not a caller of this one', + 'Circle.area' in r and 'squareOnly' not in r, r) + print() print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) sys.exit(1 if fails else 0) diff --git a/tests/mcp.py b/tests/mcp.py index d823c2a8e..faef7ec79 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -152,6 +152,11 @@ def check_words(): (" --tests-only lists all 22 by rung and file; --why adds each one's route", " tests=True lists all 22 by rung and file; why=True adds each one's route"), ("ask for --page 2", "ask for page=2"), + # `--page all` is the string "all" here, and the footer names one spelling per surface, never both + ("ask for the next with --page 2, or all of it with --page all; --budget N changes the page size", + 'ask for the next with page=2, or all of it with page="all"; budget=N changes the page size'), + ("narrow instead with --in , --depth N or --tests-only", "narrow instead with in_path=, depth=N or tests=True"), + ("--page N|all", 'page=N or page="all"'), ("narrow with `impact --in ` or `path '*' --in parser/src`.", "narrow with `impact in_path=` or `path '*' in_path=parser/src`."), ("start at --from ", "start at from_="), @@ -163,6 +168,7 @@ def check_words(): (" 49 | args = ['--in', path, '--tests-only']", " 49 | args = ['--in', path, '--tests-only']"), (" | … +23 more line(s) --limit", " | … +23 more line(s) --limit"), ("a pre-built --lang java graph", "a pre-built --lang java graph"), + ('grep -rnw "all" . lists them', 'grep -rnw "all" . lists them'), # a site of a grep-shaped answer is the file's own text: a flag written in that code stays as written ("tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]", "tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]"), @@ -185,6 +191,7 @@ def check_grep_default(): lambda: server.axiomcode_impact(['A.f'], tests=True, limit=5)] prose = [lambda: server.axiomcode_impact(['A.f'], full=True), lambda: server.axiomcode_impact(['A.f'], delete=True), lambda: server.axiomcode_impact(['A.f'], why=True, tests=True), lambda: server.axiomcode_impact(['A.f'], page=2), + lambda: server.axiomcode_impact(['A.f'], page='all'), lambda: server.axiomcode_impact(['A.f'], page='2'), lambda: server.axiomcode_path('a', 'b', full=True), lambda: server.axiomcode_context('how', source=True), lambda: server.axiomcode_context('how', from_='main'), lambda: server.axiomcode_test_impact(why=True), lambda: server.axiomcode_changed()] @@ -198,6 +205,16 @@ def check_grep_default(): for f in prose: seen.clear(); f() if any(a.startswith('--grep') for a in seen[0]): bad.append(f"prose asked for, got --grep: {seen[0]}") + # page="all" is what an answer's footer tells an MCP caller to send: it reaches the CLI as --page all, and the + # SDK-free server's schema takes it as it takes a number (the control: a boolean is still refused) + seen.clear(); server.axiomcode_impact(['A.f'], page='all') + if '--page all' not in ' '.join(seen[0]): bad.append(f"page='all' did not ask for --page all: {seen[0]}") + seen.clear(); server.axiomcode_impact(['A.f'], page=1) + if '--page' in seen[0]: bad.append(f"page=1 passed --page: {seen[0]}") + import _fallback + sch = _fallback._schema_for(server.Page, 1) + for v, ok in (('all', True), (2, True), (True, False)): + if _fallback._conforms(v, sch) != ok: bad.append(f"fallback schema {sch} {'refused' if ok else 'took'} page={v!r}") return bad finally: server.run = real diff --git a/tests/multi_language.py b/tests/multi_language.py index 773e85e20..7f0f644d1 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -46,6 +46,8 @@ 'tools/pkg/calc.py': 'def add(a, b):\n return a + b\n\n\ndef total(xs):\n t = 0\n for x in xs:\n t = add(t, x)\n return t\n', 'tools/pkg/cli.py': 'from pkg.calc import total\n\n\ndef main():\n return total([1, 2])\n', 'tools/gen/make.py': 'def emit(x):\n return x\n\n\ndef main():\n return emit(1)\n', + # a directory whose name CONTAINS the scope `tools` and is not under it + 'lib/pytools/probe.py': 'def emit(x):\n return [x]\n', 'src/cli.ts': 'import { run } from \'./main\'\n\nexport function main(): number {\n return run()\n}\n', 'jslib/package.json': '{ "name": "jslib", "version": "1.0.0", "main": "index.js" }\n', 'jslib/index.js': 'function helper(a) {\n return a + 1\n}\n\nfunction api(a) {\n return helper(a) * 2\n}\n\nmodule.exports = { api }\n', @@ -179,10 +181,37 @@ def check(ok, why, detail=''): check(s.returncode == 0 and '══' not in s.stdout and 'src/' in s.stdout, 'scope (control): --in the main graph\'s directory answers as the main graph alone', s.stdout + s.stderr) s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) - check(s.returncode != 0 and 'no indexed file' in s.stdout, 'scope (control): a directory no graph holds is still refused', s.stdout + s.stderr) + # a directory no graph holds is a typo, not a question about nothing: answered at the root, said ONCE, never one + # refusal menu per language (the scope another graph holds, above, is still answered from that graph) + check(s.returncode == 0 and s.stdout.count("no indexed file in any graph has 'tools/nosuch'") == 1 + and 'answering at the repository root' in s.stdout and 'tools/pkg/calc.py' in s.stdout + and 're-run with one of these' not in s.stdout, + 'scope: a directory no graph holds is answered at the repository root, and said once', s.stdout + s.stderr) s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode != 0 and 'none of these words appear under it' in s.stdout and 'no indexed file' not in s.stdout, 'scope (control): a scope one graph holds, with nothing under it matching, is refused by that graph alone', s.stdout + s.stderr) + # #1584: `main` is declared in the typescript graph (src/cli.ts) and twice in the python one. With a scope, only + # the declaration under it is the target: not the other language's, and not the same language's elsewhere + s = sh(repo, AX, 'impact', 'main', '.', '--in', 'tools/pkg', env=quiet) + check(s.returncode == 0 and 'tools/pkg/cli.py' in s.stdout and 'src/cli.ts' not in s.stdout + and 'tools/gen/make.py' not in s.stdout and 'typescript graph' not in s.stdout, + 'scope: impact --in picks the declaration under the scope, in the one graph that holds it', s.stdout + s.stderr) + s = sh(repo, AX, 'impact', 'main', '.', env=quiet) + check(s.returncode == 0 and 'src/cli.ts' in s.stdout and 'tools/pkg/cli.py' in s.stdout and 'tools/gen/make.py' in s.stdout, + 'scope (control): impact without --in still answers for every declaration, in every graph', s.stdout + s.stderr) + s = sh(repo, AX, 'impact', 'square', '.', '--in', 'src/shape.ts', env=quiet) + check(s.returncode == 0 and 'area' in s.stdout and 'src/util.ts' in s.stdout, + 'scope (control): a name declared outside the scope still answers who under it uses it', s.stdout + s.stderr) + # a scope that names a directory of the repository matches by prefix, not as a substring of another directory + s = sh(repo, AX, 'impact', 'emit', '.', '--in', 'tools', env=quiet) + check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, + 'scope: --in tools is the tools/ directory, not lib/pytools/', s.stdout + s.stderr) + s = sh(repo, AX, 'impact', 'emit', '.', '--in', 'pytools', env=quiet) + check(s.returncode == 0 and 'lib/pytools/probe.py' in s.stdout and 'tools/gen/make.py' not in s.stdout, + 'scope (control): a scope that names no path of the repository still matches anywhere', s.stdout + s.stderr) + s = sh(repo, AX, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) + check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, + 'scope: context --in tools keeps lib/pytools/ out too', s.stdout + s.stderr) # ── --from ──────────────────────────────────────────────────────────────────────────────────────────── # `main` is declared in the typescript graph and twice in the python one: the flow starts where the task's diff --git a/tests/run.py b/tests/run.py index a4bba1911..dbc733636 100755 --- a/tests/run.py +++ b/tests/run.py @@ -55,7 +55,10 @@ tot += 1 out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True) text = out.stdout + out.stderr - bad = [w for w in ch.get('want', []) if w not in text] + [f"(present) {w}" for w in ch.get('avoid', []) if w in text] + # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the + # case file lies in the searched tree): its own `avoid` strings there are not the tool's answer + said = re.sub(r'(?m)^\s*\[text\] case\.json:\d+.*\n?', '', text) + bad = [w for w in ch.get('want', []) if w not in text] + [f"(present) {w}" for w in ch.get('avoid', []) if w in said] # "stdout_json": true — STDOUT alone must parse as one JSON document. A diagnostic printed beside the answer # is invisible to a substring check (want/avoid read both streams together) and fatal to a consumer, which is # how a `note:` line sat inside --json for every name declared as both a field and a method. diff --git a/tests/script_tests.py b/tests/script_tests.py new file mode 100644 index 000000000..eb60a43c1 --- /dev/null +++ b/tests/script_tests.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""tests/script_tests.py — test-impact selects a script-style runner, with the command the project runs it by (T2-1). + +A runner under the test tree that calls no test framework — `test/run.js` calling `runCase(...)` at top level, a +`tests/check.py` with its own main guard — was walked to and labelled [test] by impact, then counted "0 of 0", and +test-impact said no test reaches the change. Each run copies the golden fixture into a throwaway git repository, +indexes it, edits the changed function's body and asks test-impact what to run. The script is selected with its own +command (the package script that names it, or the interpreter) and a framework test beside it keeps its command. + + python3 tests/script_tests.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') +CASES = os.path.join(ROOT, 'tests', 'cases') + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + # (language, fixture, --src, file to edit, old text, new text, wanted lines, unwanted lines) + runs = [ + ('javascript', 'script-runner-is-a-test', 'src', 'src/lib/text.js', 'trim().toLowerCase()', 'trim().toLowerCase().normalize()', + ['src/test/run.js', '(cd src && npm run test)', 'node src/test/smoke.js', '(cd src && npx vitest run test/text.test.js)'], + ['no test in the graph reaches', 'src/test/helpers.js']), + ('python', 'script-runner-is-a-test', '.', 'app/pricing.py', 'amount * 1.2', 'amount * 1.25', + ['tests/check_prices.py', 'python tests/check_prices.py', 'pytest tests/test_discount.py'], + ['no test in the graph reaches', 'tests/fixtures/server.py', 'pytest tests/check_prices.py']), + ] + for lang, case, src, rel, old, new, want, avoid in runs: + work = tempfile.mkdtemp(prefix='axiomcode-script-tests-') + try: + repo = os.path.join(work, 'repo') + shutil.copytree(os.path.join(CASES, lang, case), repo, ignore=shutil.ignore_patterns('.axiomcode', 'case.json')) + for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), + ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): + sh(repo, *cmd) + built = sh(repo, AX, 'index', '.', '--lang', lang, '--src', src, env=env) + check(built.returncode == 0, f'{lang}: the fixture indexes', built.stdout + built.stderr) + f = os.path.join(repo, rel); text = open(f).read() + open(f, 'w').write(text.replace(old, new)) + r = sh(repo, AX, 'test-impact', '.', env=env) + out = r.stdout + r.stderr + check(r.returncode == 0 and 'Traceback' not in out, f'{lang}: test-impact answers', out) + for w in want: check(w in out, f'{lang}: test-impact names {w!r}', out) + for a in avoid: check(a not in out, f'{lang}: test-impact does not name {a!r}', out) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'ok' if not fails else f'{len(fails)} FAILED'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_command.py b/tests/test_command.py index b045d78eb..e9a227aec 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""tests/test_command.py — the command test-impact prints for a TypeScript or JavaScript selection runs those files. +"""tests/test_command.py — the command test-impact prints runs the selected files, with the runner their project uses. + +A Python file is run by its project's runner: `python manage.py test ` for Django with no pytest, `python -m +unittest` for TestCase modules with no pytest, pytest otherwise; each runner is handed only its own language's files. `npx vitest run ` on a file vitest does not collect exits 1 with "No test files found" (#1570). Each file is placed with the runner its own package would collect it with (that runner's include globs), else with the command @@ -310,7 +313,96 @@ def tree(files): shutil.rmtree(d) # control: other languages are untouched -check("python unchanged (control)", ti.command_for('python', ['tests/test_a.py'], []), 'pytest tests/test_a.py') +check("python with nothing to read keeps pytest (control)", ti.command_for('python', ['tests/test_a.py'], []), 'pytest tests/test_a.py') + +# a Python test file is run by the runner its project uses: Django's manage.py test, unittest, or pytest +DJ_MANAGE = "import os, sys\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hc.settings')\nfrom django.core.management import execute_from_command_line\n" +UT = "import unittest\nclass T(unittest.TestCase):\n def test_a(self): pass\n" +DJ_T = "from django.test import TestCase\nclass T(TestCase):\n def test_a(self): pass\n" +for why, files, sel, want in [ + ("django: manage.py and no pytest anywhere runs manage.py test with dotted labels", + {'manage.py': DJ_MANAGE, 'requirements.txt': 'Django==5.0\n', 'hc/api/tests/test_check.py': DJ_T}, + ['hc/api/tests/test_check.py'], 'python manage.py test hc.api.tests.test_check'), + ("django: pytest-django in the requirements keeps pytest (near miss)", + {'manage.py': DJ_MANAGE, 'requirements-dev.txt': 'pytest-django==4.8\n', 'hc/api/tests/test_check.py': DJ_T}, + ['hc/api/tests/test_check.py'], 'pytest hc/api/tests/test_check.py'), + ("django: a [tool.pytest.ini_options] section keeps pytest (near miss)", + {'manage.py': DJ_MANAGE, 'pyproject.toml': '[tool.pytest.ini_options]\nDJANGO_SETTINGS_MODULE = "x"\n', 'app/tests.py': DJ_T}, + ['app/tests.py'], 'pytest app/tests.py'), + ("django: a conftest.py above the test keeps pytest (near miss)", + {'manage.py': DJ_MANAGE, 'app/conftest.py': '', 'app/tests/test_x.py': DJ_T}, + ['app/tests/test_x.py'], 'pytest app/tests/test_x.py'), + ("django: manage.py in a subdirectory runs from there", + {'src/manage.py': DJ_MANAGE, 'src/requirements.txt': 'Django\n', 'src/shop/tests/test_cart.py': DJ_T}, + ['src/shop/tests/test_cart.py'], '(cd src && python manage.py test shop.tests.test_cart)'), + ("a manage.py that is not Django's (a Flask CLI) is not a Django runner (near miss)", + {'manage.py': 'from flask.cli import FlaskGroup\n', 'tests/test_x.py': 'def test_a(): pass\n'}, + ['tests/test_x.py'], 'pytest tests/test_x.py'), + ("CI that runs manage.py test is read as the runner", + {'manage.py': DJ_MANAGE, '.github/workflows/ci.yml': 'run: python manage.py test\n', 'requirements.txt': 'Django\n', + 'a/tests.py': DJ_T}, ['a/tests.py'], 'python manage.py test a.tests'), + ("unittest: TestCase modules with no pytest anywhere run under python -m unittest", + {'setup.py': 'from setuptools import setup\n', 'tests/test_x.py': UT}, ['tests/test_x.py'], 'python -m unittest tests.test_x'), + ("unittest: tox that runs -m unittest is read as the runner", + {'tox.ini': '[testenv]\ncommands = python -m unittest discover\n', 'tests/test_x.py': UT}, ['tests/test_x.py'], + 'python -m unittest tests.test_x'), + ("unittest: a test importing pytest keeps pytest (near miss)", + {'setup.py': '', 'tests/test_x.py': 'import pytest, unittest\nclass T(unittest.TestCase): pass\n'}, + ['tests/test_x.py'], 'pytest tests/test_x.py'), + ("unittest: a bare def test_ needs pytest (near miss)", + {'setup.py': '', 'tests/test_x.py': 'def test_a(): pass\n'}, ['tests/test_x.py'], 'pytest tests/test_x.py'), + ("a test script (no test function, exits on its own) is run as a program, the tests beside it with pytest", + {'pyproject.toml': '[project]\ndependencies = ["pytest"]\n', 'tests/test_x.py': 'def test_a(): pass\n', + 'tests/test_cmd.py': 'import sys\nfails = 0\nsys.exit(1 if fails else 0)\n'}, + ['tests/test_cmd.py', 'tests/test_x.py'], 'pytest tests/test_x.py\npython tests/test_cmd.py'), + ("a module under a test tree that declares no test and runs as no program is on no command line", + {'pyproject.toml': '[project]\ndependencies = ["pytest"]\n', 'tests/test_x.py': 'def test_a(): pass\n', + 'tests/cases/shop/pricing.py': 'def price(q):\n return q\n'}, + ['tests/cases/shop/pricing.py', 'tests/test_x.py'], 'pytest tests/test_x.py'), + ("a runner only gets files of its language: a .cs and a .ts fixture are left off the pytest line", + {'pyproject.toml': '[project]\ndependencies = ["pytest"]\n', 'tests/test_x.py': ''}, + ['tests/test_x.py', 'tests/cases/Foo.cs', 'tests/cases/a.ts'], 'pytest tests/test_x.py'), +]: + d = tree(files) + check(why, ti.command_for('python', sel, [], None, d), want) + shutil.rmtree(d) +# the by-name tier: a stem every package repeats names no module's tests +d = tree({'app/__init__.py': '', 'app/pricing.py': '', 'app/__main__.py': '', 'tests/__init__.py': '', + 'tests/test_pricing.py': 'def test_a(): pass\n', 'tests/sub/__init__.py': '', 'tests/conftest.py': ''}) +check("an edit to __init__.py or __main__.py names no test file by name", ti.package_tier(d, ['app/__init__.py', 'app/__main__.py'])[1], {}) +check("an edit to pricing.py still names test_pricing.py (control)", ti.package_tier(d, ['app/pricing.py'])[1], + {'files': ['tests/test_pricing.py']}) +shutil.rmtree(d) +# the by-name tier holds only tests by the changed file's own ecosystem: a fixture input under a test tree, another +# language's test and the changed file itself share the name but are counted, never offered or run +d = tree({'src/index.js': '', 'test/fixtures/basic/index.js': '', 'test/fixtures/esm/index.js': '', + 'tests/test_index.py': '', 'test/index.test.js': '', 'test/server.js': '', 'test/fixtures/app/server.js': '', + 'src/server.ts': '', 'src/schema.ts': '', 'test/fixtures/schema.ts': '', 'test/schema.spec.ts': '', + 'test/app.ts': '', 'test/fixtures/app/index.js': ''}) +check("fixture inputs and another language's test are not the tests of src/index.js", + ti.package_tier(d, ['src/index.js'])[1], {'files': ['test/index.test.js'], 'not_tests': 4}) +check("a .spec.ts names schema.ts; the fixture schema.ts is counted, not offered", + ti.package_tier(d, ['src/schema.ts'])[1], {'files': ['test/schema.spec.ts'], 'not_tests': 1}) +check("a file directly in test/ is a mocha test of server.ts (control); the nested fixture server.js is not", + ti.package_tier(d, ['src/server.ts'])[1], {'files': ['test/server.js'], 'not_tests': 1}) +check("a changed test-tree file is not its own test", ti.package_tier(d, ['test/fixtures/basic/index.js'])[1].get('files'), + ['test/index.test.js']) +shutil.rmtree(d) +d = tree({'src/main/java/a/Parser.java': '', 'src/test/java/a/ParserTest.java': '', 'src/test/java/a/TestParser.java': '', + 'src/test/resources/cases/Parser.java': '', 'tests/test_parser.py': '', 'src/test/java/a/Testimonial.java': ''}) +check("java: ParserTest and TestParser are Parser's tests; a resource Parser.java is not", + ti.package_tier(d, ['src/main/java/a/Parser.java'])[1], + {'files': ['src/test/java/a/ParserTest.java', 'src/test/java/a/TestParser.java'], 'not_tests': 1}) +shutil.rmtree(d) +check("a runner given only another language's files prints no command", ti.command_for('python', ['tests/Foo.cs'], [], None, '.'), None) +check("java drops a .py file from a file-named selection", ti.command_for('java', ['src/test/java/ATest.java', 'tests/test_a.py'], []), + 'mvn test -Dtest=ATest') +check("csharp drops a .java file from a file-named selection", ti.command_for('csharp', ['T/ATests.cs', 'x/BTest.java'], []), + 'dotnet test --filter "FullyQualifiedName~ATests"') +for lang, rel, want in [('python', 'tests/test_a.py', True), ('python', 'tests/cases/fixture.py', False), + ('python', 'tests/cases/Foo.cs', False), ('java', 'src/test/java/ATest.java', True), + ('java', 'tests/test_a.py', False), ('csharp', 'T/ATests.cs', True)]: + check(f"collected_by({lang}, {rel}) is {want}", ti.collected_by(lang, rel), want) check("java unchanged (control)", ti.command_for('java', [], ['app.ATest']), 'mvn test -Dtest=ATest') # the next: line names every command, and still names a single one as it did @@ -319,6 +411,14 @@ def tree(files): check("next: a (cd pkg && …) command is recognised", ax_pages.next_test_impact("\n (cd parser && npx tsx src/test/a-tests.ts)\n").split(' — ')[0], 'next: run (cd parser && npx tsx src/test/a-tests.ts)') +check("next: a manage.py test command is recognised", + ax_pages.next_test_impact("\n python manage.py test hc.api.tests.test_x\n").split(' — ')[0], + 'next: run python manage.py test hc.api.tests.test_x') +check("next: a python -m unittest command is recognised", + ax_pages.next_test_impact("\n (cd src && python -m unittest a.test_b)\n").split(' — ')[0], + 'next: run (cd src && python -m unittest a.test_b)') +check("next: a test script run as a program is recognised", + ax_pages.next_test_impact("\n python tests/test_cmd.py\n").split(' — ')[0], 'next: run python tests/test_cmd.py') check("next: several commands are counted", ax_pages.next_test_impact("\n (cd p && npx tsx a.ts)\n (cd p && npx tsx b.ts)\n").startswith('next: run the 2 commands above'), True)