From 699d90e7f6453a2eed986e8332ede0f60c118510 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:57:40 -0700 Subject: [PATCH] impact: a callback or dependency given to one instance reaches callers only through that instance - JavaScript engine: a new expression of a class whose instance code calls a value carries its allocation beside the instance; instance-state.dl names the edges that hold only for instances given the callee (constructor option, subscription, injected dependency) and the allocations each caller's receiver may be - a callee that also reaches the class another way (named inside it, returned to it, written onto it from outside, a class hierarchy, an entry with an unknown receiver) is not gated - impact walks a gated edge through the class's own code and leaves it only for a caller whose receiver may be an allocation given the callee, or is unknown; the SQL port does the same Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/README.md | 1 + .../engine/resolution/instance-state.dl | 129 ++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 23 ++++ graph/javascript/souffle/export_manifest.tsv | 5 + .../skills/axiomcode/scripts/axiomcode-impact | 11 +- .../skills/axiomcode/scripts/dl/impact.dl | 45 +++++- .../skills/axiomcode/scripts/graph_sql.py | 52 +++++-- .../per-instance-registration/case.json | 106 ++++++++++++++ .../per-instance-registration/package.json | 1 + .../per-instance-registration/src/a.js | 6 + .../per-instance-registration/src/bus.js | 13 ++ .../per-instance-registration/src/c.js | 3 + .../per-instance-registration/src/d.js | 23 ++++ .../per-instance-registration/src/deps.js | 13 ++ .../per-instance-registration/test/a.test.js | 3 + .../per-instance-registration/test/c.test.js | 3 + .../per-instance-registration/test/r.test.js | 3 + 17 files changed, 426 insertions(+), 14 deletions(-) create mode 100644 graph/javascript/engine/resolution/instance-state.dl create mode 100644 tests/cases/javascript/per-instance-registration/case.json create mode 100644 tests/cases/javascript/per-instance-registration/package.json create mode 100644 tests/cases/javascript/per-instance-registration/src/a.js create mode 100644 tests/cases/javascript/per-instance-registration/src/bus.js create mode 100644 tests/cases/javascript/per-instance-registration/src/c.js create mode 100644 tests/cases/javascript/per-instance-registration/src/d.js create mode 100644 tests/cases/javascript/per-instance-registration/src/deps.js create mode 100644 tests/cases/javascript/per-instance-registration/test/a.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/c.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/r.test.js diff --git a/graph/javascript/README.md b/graph/javascript/README.md index 6a26b2ab..1336eecd 100644 --- a/graph/javascript/README.md +++ b/graph/javascript/README.md @@ -103,6 +103,7 @@ arrays. That is the engine, entirely. | module graph | `resolution/module-graph.dl` | one export surface for both systems, keyed by name with `default` for `module.exports = X`; a CommonJS default value's properties ARE its members | | hierarchy | `resolution/type-hierarchy.dl` | ONE closure — every heritage form inherits members, there is no `implements` | | value flow | `resolution/value-flow.dl` | the may-analysis above | +| instance state | `resolution/instance-state.dl` | an `("alloc", new-expression)` value beside `("inst", T)`, and the facts that let impact walk a callback or dependency given to ONE instance (a constructor option, a subscription) only from callers whose receiver may be that instance | | arrays | `resolution/arrays.dl` | the one platform type modelled: `push`, `[i]`, `map`, `forEach`, `for..of`, `T[]`; `Map` / `Set` as collections, including an instance of a class that extends one (#619) | | ambient | `resolution/ambient.dl` | platform names as values, so a site reached through one is classified from the value, not the syntax | | JSDoc types | `resolution/reference-types.dl` | `@param`/`@type`/`@returns`, `import()` types, typedef aliases, wrappers | diff --git a/graph/javascript/engine/resolution/instance-state.dl b/graph/javascript/engine/resolution/instance-state.dl new file mode 100644 index 00000000..95409a3c --- /dev/null +++ b/graph/javascript/engine/resolution/instance-state.dl @@ -0,0 +1,129 @@ +// ============================================================================ +// Resolution · INSTANCE STATE (what ONE object was given, not what its class was) +// +// `new Bus({ validate: check })` and `b.on(handleA)` hand a callback to ONE bus. The +// flow layer keys an instance by its class, ("inst", Bus), so `this.v` inside +// `Bus.emit` holds every callback any bus was ever given, and the call edge +// emit -> check is right for SOME bus. The edge is right; what is wrong is reading it +// as "every caller of emit reaches check": a caller that emits on a bus built without +// `validate` does not. A context-insensitive call graph cannot say that, so this file +// says it beside the graph, as facts the impact walk reads (dl/impact.dl): +// +// state_world(M, T) callable M runs with an instance of T as its `this`: +// an instance member of T, or a function nested in one +// state_gate(M, F, T) every call M makes to F reaches F only through what +// the receiver instance was given +// state_gate_alloc(T, F, S) the allocation S (a `new T(...)` expression) is one +// that was given F +// state_call_alloc(C, M, S) C calls the T member M on a receiver that may be S +// state_call_open(C, M) C calls M on a receiver whose allocation is unknown +// +// ── THE ALLOCATION VALUE ──────────────────────────────────────────────────── +// ("alloc", NewExpr) is a value of its own, carried BESIDE ("inst", T) wherever the +// instance goes (a variable, a parameter, a field, a return, `x ?? new T()`), exactly +// as ("wrap", site) travels beside a wrapper's closure (value-flow.dl). Nothing +// resolves through it: members are still read off ("inst", T). It is minted only for +// a class whose instance code CALLS A VALUE — a parameter (`f(e)`, `handler(env)`) or +// a property of `this` that the class does not declare as a method (`this.v?.(e)`) — +// which is the shape whose targets depend on what the instance was given. Every +// other class carries no allocation, and so costs nothing here. +// +// ── WHEN A CALLBACK IS GATED ──────────────────────────────────────────────── +// F is gated in T when the only way F enters T's instance code is as an argument of +// a call made from OUTSIDE that code on a T member (the constructor or a method) — +// an ENTRY — and every such entry's receiver has a known allocation. It is not gated +// (every caller keeps it, as before) when F: +// · is declared inside T's instance code; +// · is read inside that code through a name bound outside it (an import, a +// module-level variable, a function declaration), or returned to it by a call +// to a function declared outside it; +// · is written onto an instance of T, or onto the static side of T, from outside; +// · reaches T whose hierarchy has a client class above or below it, or whose own +// code builds another T (`new T()`, `new this.constructor()`): what one instance +// holds could then flow into another; +// · enters through an entry whose receiver's allocation is unknown. +// The allocations are the closed-world answer the engine gives for parameters too: a +// receiver's allocations are the `new` expressions that reach it through resolved +// flow. A receiver with none (a documented `@param {Bus}`, a library's return) is open. +// ============================================================================ + +// ── the instance code of a class ──────────────────────────────────────────── +state_world(m, t) :- method_decl("client", _, k, _, "false", t, _, m), t != "", k != "STATIC_BLOCK". +state_world(m, t) :- type_ctor("client", m, t). +state_world(m, t) :- method_enclosing(m, e), state_world(e, t). +state_type_declares(t, n) :- method_decl(_, n, _, _, _, t, _, _), n != "". + +// ── the classes whose instance code calls a value ─────────────────────────── +state_type(t) :- state_world(em, t), call_site("client", ck, n, "SYNTACTIC", _, em, ce, _, _), call_kind_is_member_form(ck), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "THIS", _, r), n != "", !state_type_declares(t, n). +state_type(t) :- state_world(em, t), call_site("client", ck, _, _, _, em, ce, _, _), call_kind_is_callee_form(ck), + expr_child(_, ce, "CALLEE", _, c), expr_param(_, _, c). + +// ── the allocation value, minted at `new T(...)` ──────────────────────────── +expr_value(e, "alloc", e) :- expr_kind("client", "NEW", _, e), new_constructs(e, t), state_type(t). +state_alloc_type(s, t) :- expr_value(s, "alloc", s), new_constructs(s, t). + +// ── eligibility: nothing one instance holds can move into another ─────────── +state_type_mixed(t) :- state_type(t), type_super(t, s), type_decl("client", _, _, _, _, s). +state_type_mixed(t) :- state_type(t), type_super(u, t), type_decl("client", _, _, _, _, u). +state_type_mixed(t) :- state_type(t), state_world(em, t), expr_kind(_, "NEW", _, e), expr_owner(_, em, _, e), new_constructs(e, t). +state_type_mixed(t) :- state_type(t), state_world(em, t), own_class_new(em, _). +state_type_ok(t) :- state_type(t), !state_type_mixed(t). + +// ── entries: a call from outside the instance code onto a member of T ─────── +state_entry(t, ce) :- state_type_ok(t), expr_resolves_to_method(ce, m), state_world(m, t), + call_site("client", _, _, _, _, em, ce, _, _), !state_world(em, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_kind(_, "NEW", _, ce), expr_value(ce, "alloc", s), state_alloc_type(s, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "alloc", s), state_alloc_type(s, t). +state_entry_open(ce) :- state_entry(_, ce), !state_entry_alloc(ce, _). + +// What an entry's arguments carry: the values themselves and, a few levels down, what +// their properties and elements hold (`{ validate: check }`, `[a, b]`). Depth-bounded: +// a carry is evidence FOR gating, so a callback found deeper than this is simply not +// counted as entering through the entry, and then it is not gated at all. +state_carry(ce, k, i, 0) :- state_entry(_, ce), call_arg(ce, _, a), expr_value(a, k, i), k != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_enters(t, f, ce) :- state_entry(t, ce), state_carry(ce, "func", f, _). + +// ── leaks: F reaches the instance code some other way ─────────────────────── +// declared inside it +state_leak(t, f) :- state_enters(t, f, _), state_world(f, t). +// a value the instance code has from anywhere but an entry: a name bound outside it +// (an import, a module-level variable or function, a class it constructs itself: `dep ?? +// new DefaultDep()` names DefaultDep, whose prototype holds `run`), or what a function +// declared outside it returns. What those hold, a few levels down, is F leaking in. A +// literal the code writes itself needs no rule: a function in it is either named (above) +// or written inline, and then it is declared inside the instance code. +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + var_owner_method(_, vm, v), !state_world(vm, t). +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + !var_owner_method(_, _, v). +state_outer_ref(t, ce) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, ce), expr_kind(_, "CALL", _, ce), + expr_resolves_to_method(ce, g), !state_world(g, t). +state_outer_carry(t, k, i, 0) :- state_outer_ref(t, e), expr_value(e, k, i), k != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_leak(t, f) :- state_enters(t, f, _), state_outer_carry(t, "func", f, _). +// written onto an instance of T or onto T itself from outside its instance code +state_leak(t, f) :- state_enters(t, f, _), expr_kind(_, "ASSIGNMENT", _, a), expr_owner(_, em, _, a), !state_world(em, t), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + expr_value(r, k, t), (k = "inst" ; k = "ctor"), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, "func", f). +state_leak(t, f) :- state_enters(t, f, _), prop_value("ctor", t, _, "func", f). +// an entry that says nothing about which instance it gives F to +state_leak(t, f) :- state_enters(t, f, ce), state_entry_open(ce). + +// ── the gate ──────────────────────────────────────────────────────────────── +state_gated(t, f) :- state_enters(t, f, _), !state_leak(t, f). +state_gate_alloc(t, f, s) :- state_gated(t, f), state_enters(t, f, ce), state_entry_alloc(ce, s). +state_gate(m, f, t) :- state_gated(t, f), state_world(m, t), expr_resolves_to_method(ce, f), + call_site("client", _, _, _, _, m, ce, _, _). + +// ── who calls the instance code, on which allocation ──────────────────────── +state_gated_type(t) :- state_gated(t, _). +state_call_alloc(c, m, s) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_alloc(ce, s). +state_call_open(c, m) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_open(ce). +state_world_of_gated(m, t) :- state_gated_type(t), state_world(m, t). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..0faf2204 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -569,3 +569,26 @@ // ── resolution/ambient.dl (the global object) ── .decl global_object_name(c0:symbol) .decl global_name_resolution(c0:symbol) + +// ── resolution/instance-state.dl ── +.decl state_world(c0:symbol, c1:symbol) +.decl state_type_declares(c0:symbol, c1:symbol) +.decl state_type(c0:symbol) +.decl state_alloc_type(c0:symbol, c1:symbol) +.decl state_type_mixed(c0:symbol) +.decl state_type_ok(c0:symbol) +.decl state_entry(c0:symbol, c1:symbol) +.decl state_entry_alloc(c0:symbol, c1:symbol) +.decl state_entry_open(c0:symbol) +.decl state_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_enters(c0:symbol, c1:symbol, c2:symbol) +.decl state_leak(c0:symbol, c1:symbol) +.decl state_outer_ref(c0:symbol, c1:symbol) +.decl state_outer_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_gated(c0:symbol, c1:symbol) +.decl state_gate_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_gate(c0:symbol, c1:symbol, c2:symbol) +.decl state_gated_type(c0:symbol) +.decl state_call_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_call_open(c0:symbol, c1:symbol) +.decl state_world_of_gated(c0:symbol, c1:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..cf9a9c0f 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -42,3 +42,8 @@ package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv jsx_renders jsx-renders.csv +state_gate state-gate.csv +state_gate_alloc state-gate-alloc.csv +state_call_alloc state-call-alloc.csv +state_call_open state-call-open.csv +state_world_of_gated state-world.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..96fa09ff 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1101,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '57' # 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 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 + IMPACT_VERSION = '58' # 58: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 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 @@ -1426,6 +1426,15 @@ class Impact: # the callables in test files: a test that publishes a handler table's key drives the handler, and is not # counted against the key the way a production writer is (dl/impact.dl, table_key) W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id'))) + # WHAT ONE INSTANCE WAS GIVEN (JavaScript, engine resolution/instance-state.dl): a callback handed to the + # constructor or subscribed through a method of ONE object is reached from the class's code only for callers + # whose receiver may be that object. The engine names the gated edges, the allocations that were given each + # callback, and the allocation (or none known) of each receiver that calls into the class; the walk in + # dl/impact.dl carries the gate through the class's own code and applies it where a caller leaves it. + for rel, t, cols in (('state_gate', 'ext_state_gate', 'c0, c1, c2'), ('state_gate_alloc', 'ext_state_gate_alloc', 'c0, c1, c2'), + ('state_call_alloc', 'ext_state_call_alloc', 'c0, c1, c2'), ('state_call_open', 'ext_state_call_open', 'c0, c1'), + ('state_world', 'ext_state_world_of_gated', 'c0, c1')): + W(rel, sorted(tuple(r) for r in g.q(f"SELECT DISTINCT {cols} FROM {t}")) if g.has(t) else []) # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..4a5d463b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -845,10 +845,44 @@ seed_byname(q, c) :- valueref(q, c, _, _), !seed(q, c). .decl up(q:symbol, m:symbol, d:number) up(q, m, 0) :- seed(q, m). up(q, c, 1) :- seed_byname(q, c). -up(q, a, d+1) :- up(q, b, d), edge(a, b, _), d < 40. +up(q, a, d+1) :- up(q, b, d), edge(a, b, _), !state_gate(a, b, _), d < 40. up(q, a, d+1) :- up(q, b, d), fw_edge(a, b, _), d < 40. +// ── WHAT ONE INSTANCE WAS GIVEN: the edge holds for some receivers only ────────────────────────────────────────── +// `new Bus({ validate: check })` and `b.on(handleA)` give ONE bus a callback, and `Bus.emit` calls it through what +// that bus holds. The engine's edge emit -> check is one edge for every bus, so the closure used to hand check to +// every caller of emit: a test that emits on a bus built without `validate` reached it. The engine now says which +// edges are of that kind and which allocations were given the callback (JavaScript resolution/instance-state.dl): +// state_gate(a, f, T) every call a makes to f reaches f only through what the receiver instance holds +// state_world(m, T) m runs with an instance of T as `this` (T's instance code, and what it nests) +// state_gate_alloc(T, f, s) the allocation s (a `new T(...)`) was given f +// state_call_alloc(c, m, s) c calls the T member m on a receiver that may be s +// state_call_open(c, m) c calls m on a receiver whose allocation is unknown: it keeps every callback +// The gate travels with the walk through T's own code (`this.deliver()` -> `this.#invoke()` -> `handler()`), and is +// applied where the walk leaves it: a caller outside T keeps the route when one of its receivers may be an +// allocation given f, or when the engine knows none. An edge with no call-site record (a dispatch choice) keeps it. +.decl state_gate(a:symbol, f:symbol, t:symbol) .input state_gate +.decl state_world(m:symbol, t:symbol) .input state_world +.decl state_gate_alloc(t:symbol, f:symbol, s:symbol) .input state_gate_alloc +.decl state_call_alloc(c:symbol, m:symbol, s:symbol) .input state_call_alloc +.decl state_call_open(c:symbol, m:symbol) .input state_call_open +.decl state_call_known(c:symbol, m:symbol) +state_call_known(c, m) :- state_call_alloc(c, m, _). +state_call_known(c, m) :- state_call_open(c, m). +.decl state_exit(c:symbol, a:symbol, t:symbol, f:symbol) +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), state_call_open(c, a). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_call_alloc(c, a, s), state_gate_alloc(t, f, s). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), !state_call_known(c, a). +// upg(q, m, T, f, d): m is reached only on the instances of T that were given f +.decl upg(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +upg(q, a, t, f, d+1) :- up(q, f, d), state_gate(a, f, t), edge(a, f, _), d < 40. +upg(q, c, t, f, d+1) :- upg(q, a, t, f, d), edge(c, a, _), state_world(c, t), d < 40. +up(q, c, d+1) :- upg(q, a, t, f, d), state_exit(c, a, t, f), d < 40. +up(q, a, d+1) :- upg(q, b, _, _, d), fw_edge(a, b, _), d < 40. +.decl up_all(q:symbol, m:symbol, d:number) +up_all(q, m, d) :- up(q, m, d). +up_all(q, m, d) :- upg(q, m, _, _, d). .decl reach(q:symbol, m:symbol, d:number) -reach(q, m, d) :- up(q, m, d), d = min x : up(q, m, x). +reach(q, m, d) :- up_all(q, m, d), d = min x : up_all(q, m, x). // the chain read-back: a is one hop further from the change than b, through edge a → b .decl parent_up(q:symbol, a:symbol, b:symbol, t:symbol) parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, edge(a, b, t). @@ -862,7 +896,12 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), d < 40. +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f +.decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. +up_running_g(q, c, t, f, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_world(c, t), d < 40. +up_running(q, c, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_exit(c, a, t, f), d < 40. .decl import_hop(q:symbol, a:symbol, b:symbol) import_hop(q, a, mod) :- up_running(q, mod, _), kind(mod, "module"), decl_file(mod, g), imports_file(f, g), decl_file(a, f), kind(a, "module"), a != mod. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db50a1a0..344c7f0a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -585,25 +585,57 @@ def _rev(edges): return r -def reach_from(rev, seeds, byname=(), cap=40): +def reach_from(rev, seeds, byname=(), cap=40, gate=None): """up/reach: everything that can reach a seed, at its SHORTEST hop count. `up(q,m,0) :- seed(q,m)` · `up(q,c,1) :- seed_byname(q,c)` · `up(q,a,d+1) :- up(q,b,d), edge(a,b,_), d0, reach(q,b,d-1), edge(a,b,t)` — a is one hop further from the change than b, so following it from any reached node walks down to a seed. @@ -3563,7 +3595,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non out = {k: [] for k in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws')} - E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); gate = state_gate(q); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) for qq in QS: # A query can carry SEVERAL target kinds at once: a name match that hits both a method and a field # resolves to both, and the rules simply union what each kind derives. Dispatch per kind and union here @@ -3803,7 +3835,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non _sde.add((c, m)); _de.append((c, m)) out['direct_edge'] += [[c, m, qq] for c, m in _de] out['seed_byname'] += [[c, qq] for c in byname] - depth = reach_from(rev, seeds, byname) + depth = reach_from(rev, seeds, byname, gate=gate) out['reach'] += [[m, str(d), qq] for m, d in depth.items()] # reach_sure: the same closure from the seeds that are an exact edge only — a seed reached ONLY through a # by-name / text / one-of-a-set dependent is weak, and the answer says how much of itself rests on those diff --git a/tests/cases/javascript/per-instance-registration/case.json b/tests/cases/javascript/per-instance-registration/case.json new file mode 100644 index 00000000..9a7d70b6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/case.json @@ -0,0 +1,106 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a callback given to ONE bus's constructor is called by emit only on that bus: impact of it lists the caller that emits on the bus built with it, not the caller that emits on a bus built without it", + "run": [ + "impact", + "check", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "a handler subscribed on one bus is reached from emit on that bus; a bus it was never subscribed on does not reach it", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "control: the registration still reaches callers whose bus is its own allocation through another object's field, through an argument, or whose allocation the graph does not know", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/d.js:11:", + "src/d.js:17:", + "src/d.js:18:", + "src/d.js:6:" + ] + }, + { + "why": "control: a handler the class writes for itself is the same on every instance, so every caller reaches it", + "run": [ + "impact", + "defaultHandler", + "--grep" + ], + "want": [ + "src/d.js:22:", + "src/d.js:23:", + "test/r.test.js" + ] + }, + { + "why": "a test that emits on a bus built without a callback does not run it", + "run": [ + "impact", + "src/c.js:3", + "--tests-only" + ], + "want": [ + "test/c.test.js" + ] + }, + { + "why": "a dependency handed to one holder's constructor is called only for that holder: impact of it lists the caller of the holder built with it, not the callers of holders built with another", + "run": [ + "impact", + "OtherDep.run", + "--grep" + ], + "want": [ + "src/deps.js:6:", + "src/deps.js:13:" + ], + "avoid": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + }, + { + "why": "control: a dependency the class builds itself when given none serves every holder built without one: its callers are kept, not narrowed to the holder given it explicitly", + "run": [ + "impact", + "DefaultDep.run", + "--grep" + ], + "want": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + } + ] +} diff --git a/tests/cases/javascript/per-instance-registration/package.json b/tests/cases/javascript/per-instance-registration/package.json new file mode 100644 index 00000000..18a0b90c --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/package.json @@ -0,0 +1 @@ +{"name":"per-instance-registration","type":"module"} diff --git a/tests/cases/javascript/per-instance-registration/src/a.js b/tests/cases/javascript/per-instance-registration/src/a.js new file mode 100644 index 00000000..b3dfc7f8 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/a.js @@ -0,0 +1,6 @@ +import { Bus } from './bus.js'; +export function check(e) { return e; } +export function handleA(e) { return e; } +const b = new Bus({ validate: check }); +b.on(handleA); +export const runA = () => b.emit(1); diff --git a/tests/cases/javascript/per-instance-registration/src/bus.js b/tests/cases/javascript/per-instance-registration/src/bus.js new file mode 100644 index 00000000..c5cb0a29 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/bus.js @@ -0,0 +1,13 @@ +// A bus holds what it was built with and what was subscribed on it. +export class Bus { + constructor({ validate } = {}) { this.v = validate; this.subs = []; } + on(f) { this.subs.push(f); } + emit(e) { this.v?.(e); this.subs.forEach((f) => f(e)); } +} + +// control: a registry whose handler is written inside the class serves every instance. +export function defaultHandler(x) { return x; } +export class Registry { + constructor() { this.h = defaultHandler; } + run(x) { return this.h(x); } +} diff --git a/tests/cases/javascript/per-instance-registration/src/c.js b/tests/cases/javascript/per-instance-registration/src/c.js new file mode 100644 index 00000000..8bc790a6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/c.js @@ -0,0 +1,3 @@ +import { Bus } from './bus.js'; +const c = new Bus(); +export const runC = () => c.emit(2); diff --git a/tests/cases/javascript/per-instance-registration/src/d.js b/tests/cases/javascript/per-instance-registration/src/d.js new file mode 100644 index 00000000..3be98815 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/d.js @@ -0,0 +1,23 @@ +import { Bus, Registry } from './bus.js'; +import { handleA } from './a.js'; + +// control: a bus whose allocation the graph does not know (a documented parameter) keeps every registration. +/** @param {Bus} bus */ +export const runD = (bus) => bus.emit(3); + +// control: the same allocation reached through a field of another object. +export class Svc { + constructor() { this.bus = new Bus(); this.bus.on(handleA); } + run() { this.bus.emit(4); } +} + +// control: an allocation passed on as an argument is still that allocation. +const shared = new Bus(); +shared.on(handleA); +function relay(bus) { bus.emit(5); } +export const runE = () => relay(shared); + +const r1 = new Registry(); +const r2 = new Registry(); +export const runR1 = () => r1.run(1); +export const runR2 = () => r2.run(2); diff --git a/tests/cases/javascript/per-instance-registration/src/deps.js b/tests/cases/javascript/per-instance-registration/src/deps.js new file mode 100644 index 00000000..3aa90a5e --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/deps.js @@ -0,0 +1,13 @@ +// A holder calls the dependency it was built with, or the one it builds when given none. +export class DefaultDep { run() { return 1; } } +export class OtherDep { run() { return 2; } } +export class Holder { + constructor(dep) { this.dep = dep ?? new DefaultDep(); } + go(f) { f(); return this.dep.run(); } +} +const h1 = new Holder(); +const h2 = new Holder(new DefaultDep()); +const h3 = new Holder(new OtherDep()); +export const goDefault = () => h1.go(() => 0); +export const goExplicit = () => h2.go(() => 0); +export const goOther = () => h3.go(() => 0); diff --git a/tests/cases/javascript/per-instance-registration/test/a.test.js b/tests/cases/javascript/per-instance-registration/test/a.test.js new file mode 100644 index 00000000..0d4d0ad3 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/a.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runA } from '../src/a.js'; +test('runA emits on the validated bus', () => { runA(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/c.test.js b/tests/cases/javascript/per-instance-registration/test/c.test.js new file mode 100644 index 00000000..65a948b9 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/c.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runC } from '../src/c.js'; +test('runC emits on a bare bus', () => { runC(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/r.test.js b/tests/cases/javascript/per-instance-registration/test/r.test.js new file mode 100644 index 00000000..addb356b --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/r.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runR2 } from '../src/d.js'; +test('the second registry runs its handler', () => { runR2(); });