From 58c0be5cb6637d650df9c469db72a5c8e0488d23 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:30:56 -0700 Subject: [PATCH] TypeScript: join a keyed in-process registry's publish to the functions filed under the same key A class that stores a function parameter in a field under a key parameter (slot.add(fn), map.set(k, fn), obj[k] = fn) and another method that reads that field by key and calls what it gets is now joined caller to caller: each publish site reaches the functions registered under an equal key (tier event_dispatch), a run-time key reaches all of them, and a constant the dispatch also looks up (a wildcard) matches every publish. Wrappers that forward the key or the function, and a subscribe-all method taking a table of { [KEY]: fn } entries, are followed. The function-type envelope row that made every registered function a candidate of the handler type (key-blind) is withdrawn for such a store's parameter, its overload signatures and forwarding wrappers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../86-keyed-callback-registry/src/app.ts | 68 +++++ .../86-keyed-callback-registry/src/bus.ts | 57 ++++ .../86-keyed-callback-registry/src/topics.ts | 4 + .../expected/86-keyed-callback-registry.edges | 60 ++++ .../86-keyed-callback-registry.entries | 13 + .../86-keyed-callback-registry.envelope | 1 + .../86-keyed-callback-registry.fields | 12 + .../86-keyed-callback-registry.fields-oracle | 14 + .../86-keyed-callback-registry.known-missing | 10 + .../86-keyed-callback-registry.oracle | 3 + .../86-keyed-callback-registry.type-use | 14 + .../86-keyed-callback-registry.types-oracle | 7 + .../test/typescript/tools/normalize_edges.py | 5 +- .../call-edge-generation/keyed_registry.dl | 267 ++++++++++++++++++ .../engine/resolution/value-flow.dl | 5 +- .../keyed-callback-registry/case.json | 46 +++ .../keyed-callback-registry/src/app.test.ts | 10 + .../keyed-callback-registry/src/app.ts | 68 +++++ .../keyed-callback-registry/src/bus.ts | 59 ++++ .../keyed-callback-registry/src/topics.ts | 4 + 22 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts create mode 100644 graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.edges create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.entries create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.envelope create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.fields create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.known-missing create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.oracle create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.type-use create mode 100644 graph/test/typescript/expected/86-keyed-callback-registry.types-oracle create mode 100644 graph/typescript/engine/call-edge-generation/keyed_registry.dl create mode 100644 tests/cases/typescript/keyed-callback-registry/case.json create mode 100644 tests/cases/typescript/keyed-callback-registry/src/app.test.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/app.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/bus.ts create mode 100644 tests/cases/typescript/keyed-callback-registry/src/topics.ts diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 65f9a6c0..2c17b391 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -557,6 +557,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | | `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | +| `event_dispatch` | typescript | A publish on a project class's keyed registry reaching each function filed under the same key: `bus.publish('x', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe('x', f)` stored there, or an entry `{ ['x']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl). | | `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity's type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | @@ -577,7 +578,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row. - **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 00335314..2e9af322 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -514,6 +514,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: T, meaning: 'A publish on a project class\'s keyed registry reaching each function filed under the same key: `bus.publish(\'x\', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe(\'x\', f)` stored there, or an entry `{ [\'x\']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl).' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity\'s type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, @@ -755,7 +756,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..208bd4bc --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts @@ -0,0 +1,57 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const; diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.edges b/graph/test/typescript/expected/86-keyed-callback-registry.edges new file mode 100644 index 00000000..38603009 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.edges @@ -0,0 +1,60 @@ +ambiguous_unknown CONSTRUCTOR_CALL Bus#subscribe(string,Handler) @L12 -> - +ambiguous_unknown CONSTRUCTOR_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L47 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L7 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#(?) @L55 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL Bus#() @L17 -> - +ambiguous_unknown METHOD_CALL Bus#count(string) @L31 -> - +ambiguous_unknown METHOD_CALL Bus#dispatch(string,unknown) @L40 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L10 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L13 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L15 -> - +ambiguous_unknown METHOD_CALL Bus#subscribeAll(Record) @L23 -> - +ambiguous_unknown METHOD_CALL Indexer#dropped(unknown) @L30 -> - +ambiguous_unknown METHOD_CALL Indexer#indexed(unknown) @L27 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L55 -> - +ambiguous_unknown METHOD_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown METHOD_CALL app#onA() @L55 -> - +ambiguous_unknown METHOD_CALL app#onAll() @L58 -> - +ambiguous_unknown METHOD_CALL app#onAnything(unknown) @L13 -> - +ambiguous_unknown METHOD_CALL app#onB() @L61 -> - +ambiguous_unknown METHOD_CALL app#onCreated(unknown) @L7 -> - +ambiguous_unknown METHOD_CALL app#onRemoved(unknown) @L10 -> - +callback_registered METHOD_CALL MiniBus#fire(string,unknown) @L55 -> MiniBus#(?) +event_dispatch METHOD_CALL app#create() @L40 -> Indexer#(?) +event_dispatch METHOD_CALL app#create() @L40 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#create() @L40 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#go() @L67 -> app#onA() +event_dispatch METHOD_CALL app#go() @L67 -> app#onAll() +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onRemoved(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> Indexer#(?) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onRemoved(unknown) +known_edge CONSTRUCTOR_CALL app#() @L37 -> Indexer#(Bus) +known_edge CONSTRUCTOR_CALL app#() @L4 -> Bus#() +known_edge CONSTRUCTOR_CALL app#() @L53 -> MiniBus#() +known_edge FUNCTION_CALL Bus#dispatch(string,unknown) @L41 -> bus#(unknown) +known_edge FUNCTION_CALL Bus#publish(string,unknown) @L36 -> Bus#run() +known_edge METHOD_CALL Bus#run() @L35 -> Bus#dispatch(string,unknown) +known_edge METHOD_CALL Bus#subscribeAll(Record) @L25 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL Indexer#(?) @L21 -> Indexer#indexed(unknown) +known_edge METHOD_CALL Indexer#(?) @L22 -> Indexer#dropped(unknown) +known_edge METHOD_CALL Indexer#start() @L20 -> Bus#subscribeAll(Record) +known_edge METHOD_CALL app#() @L34 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L35 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L36 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L37 -> Indexer#start() +known_edge METHOD_CALL app#() @L63 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L64 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L65 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#create() @L40 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#go() @L67 -> MiniBus#fire(string,unknown) +known_edge METHOD_CALL app#relay({ type: string }) @L47 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#remove() @L43 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#size() @L50 -> Bus#count(string) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.entries b/graph/test/typescript/expected/86-keyed-callback-registry.entries new file mode 100644 index 00000000..afec440b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + exported_from_entry_module app#create app.ts:39 + exported_from_entry_module app#go app.ts:66 + exported_from_entry_module app#onA app.ts:54 + exported_from_entry_module app#onAll app.ts:57 + exported_from_entry_module app#onAnything app.ts:12 + exported_from_entry_module app#onB app.ts:60 + exported_from_entry_module app#onCreated app.ts:6 + exported_from_entry_module app#onRemoved app.ts:9 + exported_from_entry_module app#relay app.ts:46 + exported_from_entry_module app#remove app.ts:42 + exported_from_entry_module app#size app.ts:49 + unimported_module app# app.ts:1 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.envelope b/graph/test/typescript/expected/86-keyed-callback-registry.envelope new file mode 100644 index 00000000..0a65686b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.envelope @@ -0,0 +1 @@ +value bus#@9:41 -> bus#Bus.@16 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields b/graph/test/typescript/expected/86-keyed-callback-registry.fields new file mode 100644 index 00000000..02ad1620 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields @@ -0,0 +1,12 @@ +ambiguous_unknown read Bus#count(string) -> - +ambiguous_unknown read Indexer#start() -> - +ambiguous_unknown read app#() -> - +ambiguous_unknown read app#create() -> - +ambiguous_unknown read app#size() -> - +known_edge read Bus#count(string) -> Bus#handlers +known_edge read Bus#dispatch(string,unknown) -> Bus#handlers +known_edge read Bus#subscribe(string,Handler) -> Bus#handlers +known_edge read Indexer#start() -> Indexer#b +known_edge read MiniBus#fire(string,unknown) -> MiniBus#h +known_edge read MiniBus#on(string,(p: unknown) =) -> MiniBus#h +known_edge read app#relay({ type: string }) -> { type: string }#type diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle new file mode 100644 index 00000000..9148ff26 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle @@ -0,0 +1,14 @@ +86-keyed-callback-registry [fields] + precision 0.8571 (6 correct, 1 wrong) + recall 0.5000 (6 of 12 the compiler resolved) + sites 17 resolved 11 (64.7%) + tiers ambiguous_unknown=6 known_edge=11 + access read=17 + not scored: 6 rows whose target is not a client declaration + WRONG app#relay({ type: string }) READ { type: string }#type + MISSING Indexer#start() READ topics#created + MISSING Indexer#start() READ topics#removed + MISSING app#() READ topics#created + MISSING app#create() READ topics#created + MISSING app#relay({ type: string }) READ app#type + MISSING app#size() READ topics#created diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.known-missing b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing new file mode 100644 index 00000000..5a8e9f31 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing @@ -0,0 +1,10 @@ +# Accepted gaps for 86-keyed-callback-registry — 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 call through an element of a Set held in a Map resolves, for the compiler, to the element's +# function type; with no standard library staged the engine cannot type the Set's elements, so the +# call site stays unresolved (Bus.dispatch annotates its array, so its call resolves). The functions that call RUNS are reached by the keyed-registry edges +# (event_dispatch) from each publisher, which is what this case is about. +MiniBus#(?) -> MiniBus#(unknown) +MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.oracle b/graph/test/typescript/expected/86-keyed-callback-registry.oracle new file mode 100644 index 00000000..ee8df0d7 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.oracle @@ -0,0 +1,3 @@ +oracle=20 engine=18 agree=18 missing=2 (known 2, NEW 0) extra=0 + known MiniBus#(?) -> MiniBus#(unknown) + known MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.type-use b/graph/test/typescript/expected/86-keyed-callback-registry.type-use new file mode 100644 index 00000000..1f405a87 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.type-use @@ -0,0 +1,14 @@ +ambiguous_unknown AS_TARGET 0 topics [EXPRESSION] -> - +ambiguous_unknown METHOD_PARAM 0 Bus [METHOD_PARAM] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 Bus [EXPRESSION] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 MiniBus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 Bus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 MiniBus [EXPRESSION] -> - +known_edge METHOD_PARAM 0 Bus [METHOD_PARAM] -> Handler +known_edge METHOD_PARAM 0 Indexer [METHOD_PARAM] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Indexer +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> MiniBus +known_edge TYPE_ARGUMENT 1 Bus [EXPRESSION] -> Handler +known_edge TYPE_ARGUMENT 1 Bus [METHOD_PARAM] -> Handler +known_edge TYPE_ELEMENT 1 Bus [VARIABLE] -> Handler diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle new file mode 100644 index 00000000..82d34b05 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle @@ -0,0 +1,7 @@ +86-keyed-callback-registry [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 16 resolved 8 (50.0%) + tiers ambiguous_unknown=8 known_edge=8 + contexts AS_TARGET=1 METHOD_PARAM=3 METHOD_TYPE_ARGUMENT=2 OBJECT_CREATION_TYPE=7 TYPE_ARGUMENT=2 TYPE_ELEMENT=1 + not scored: 8 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py index 291ccaa4..9807d757 100644 --- a/graph/test/typescript/tools/normalize_edges.py +++ b/graph/test/typescript/tools/normalize_edges.py @@ -283,8 +283,9 @@ def main(): if f[1] not in n.m or f[3] not in n.m: continue # a function the site HANDS OVER (`xs.map(cb)`) is not a call the compiler lists at that - # site: the pairs are scored against the compiler, the .edges golden still carries it - if f[5] == 'callback_registered': + # site, nor is the handler a publish reaches through a keyed registry (`event_dispatch`): the + # pairs are scored against the compiler, the .edges golden still carries both + if f[5] in ('callback_registered', 'event_dispatch'): continue seen.add(f"{n.label(f[1])} -> {n.label(f[3])}") else: diff --git a/graph/typescript/engine/call-edge-generation/keyed_registry.dl b/graph/typescript/engine/call-edge-generation/keyed_registry.dl new file mode 100644 index 00000000..b1eabc1d --- /dev/null +++ b/graph/typescript/engine/call-edge-generation/keyed_registry.dl @@ -0,0 +1,267 @@ +// ============================================================================ +// CALL-EDGE-GEN · A CALLBACK KEPT IN A KEYED REGISTRY (the in-process bus) +// +// An in-process publish/subscribe is a class that keeps functions in a field keyed by a +// name and runs the ones under a name when that name is published: +// +// subscribe(name, fn) { (this.handlers.get(name) ?? …).add(fn); } +// publish(name, p) { for (const h of this.handlers.get(name) ?? []) h(p); } +// bus.subscribe('order.placed', onPlaced); … bus.publish('order.placed', o); +// +// Neither end calls the other. The call inside `publish` goes through a variable the +// registry filled, and the only rule that reached a handler from there was the function-type +// envelope (value-flow.dl, typed_holder_signature): every function ever passed where the +// handler type is expected became a candidate of that type's signature, so a publish on one +// name reached the handlers of every name, and of every other registry typed the same way. +// +// What ties the two ends together is the KEY both callers write. So the join is made between +// the CALLERS of the two methods, as the messaging rules join a producer to a consumer +// (framework-behavior/destinations.dl), but in process and on the project's own class: +// +// * a STORE: a method puts its function parameter into a field under its key parameter — +// `slot.add(fn)` / `push` / `unshift` on the slot `this.F.get(k)` or `this.F[k]` (directly, +// through `??`, `!`, `as`, or a variable it initialises), `this.F.set(k, fn)`, `this.F[k] = fn`; +// * a DISPATCH: a method reads the same field under its key parameter and CALLS what it gets — +// `for (const h of slot)`, a spread of the slot into an array it iterates, `slot.forEach(h => h())`. +// A read that calls nothing (`this.F.get(k)?.size`) is not a dispatch; +// * each is followed through wrappers that pass their own parameter on (`publish` forwarding the +// name to a private `dispatch`, from inside a closure too), and a method that subscribes each +// entry of an object parameter under its own key (`subscribeAll({ [K]: fn })`) registers every +// entry of the literal its callers pass; +// * a key is a string literal, a const holding one, or a member of a const object literal +// (`as const` too), local or imported. A key known only at run time matches every key. A constant +// key the dispatch ALSO looks up (`this.F.get('*')`) makes a registration under it match every +// publish. Not destinations.dl's rd_val, which reads more: that reader is fed by call_chain_edge +// (its wrapper hop) and negates inside, so an edge here built on it could not be stratified. +// +// The edge runs from the PUBLISH call site to the registered function, tier event_dispatch: the +// site does not call the handler, the registry does, as the JavaScript engine records an emitter's +// `emit` reaching an `on` handler. Receivers are not told apart: two instances of one bus class +// share its registrations, the precision the class gives. +// +// And the envelope row that made every handler a candidate of the handler type is withdrawn for the +// store's function parameter, but only where the registry has a dispatch this file recognised — +// otherwise it is the only route there is. +// ============================================================================ + +.decl kr_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_read(c0:symbol, c1:symbol, c2:symbol) +.decl kr_yields(c0:symbol, c1:symbol) +.decl kr_slot(c0:symbol, c1:symbol, c2:symbol) +.decl kr_adds(c0:symbol) +.decl kr_store(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_elem_holder(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatch_lit(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatched(c0:symbol) +.decl kr_site_runs(c0:symbol, c1:symbol) +.decl kr_disp(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_reg(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_table_elem(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table(c0:symbol, c1:symbol, c2:symbol) +.decl kr_key_arg(c0:symbol) +.decl kr_keyed(c0:symbol) +.decl kr_keyval(c0:symbol, c1:symbol) +.decl kr_fn(c0:symbol, c1:symbol) +.decl kr_forwards(c0:symbol) +.decl kr_registers(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table_lit(c0:symbol) +.decl kr_at(c0:symbol, c1:number, c2:number) +.decl kr_entry(c0:symbol, c1:symbol, c2:symbol) +.decl kr_static_key(c0:symbol, c1:symbol) +.decl kr_computed_root(c0:symbol, c1:symbol) +.decl kr_key_candidate(c0:symbol, c1:symbol, c2:number) +.decl kr_entry_key(c0:symbol, c1:symbol) +.decl kr_dispatch_site(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) +.decl kr_match(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl keyed_dispatch(c0:symbol, c1:symbol) +.decl kr_registry_param(c0:symbol) + +// an expression that names a parameter: (expr, the function declaring it, its position) +kr_param(e, owner, pos) :- expr_referenced("client", "PARAMETER", p, e), param_decl("client", _, pos, _, owner, p). + +// ── the registry, read under a key: `this.F.get(k)`, `this.F[k]` ───────────── +kr_read(ce, f, k) :- call_site("client", _, "get", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k). +kr_read(e, f, k) :- expr_kind("client", "ELEMENT_ACCESS", _, e), expr_child("client", e, "RECEIVER", _, r), + ts_field_access_target(r, f), expr_child("client", e, "INDEX_ARGUMENT", _, k). + +// kr_yields(X, Read): iterating or adding to X reaches the collection Read found: X is the read, +// an operand `??` / `||` / `&&` may yield, a transparent wrapper, or an array spreading it +kr_yields(e, e) :- kr_read(e, _, _). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", "BINARY_EXPRESSION", _, p), + expr_operator("client", op, p), binary_op_yields_operand(op, r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", k, _, p), + expr_kind_is_transparent(k), edge_role_is_operand(r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, "SPREAD_OPERAND", _, c), expr_kind("client", "SPREAD_ELEMENT", _, p). +kr_yields(p, e) :- kr_yields(c, e), expr_kind("client", "SPREAD_ELEMENT", _, c), expr_child("client", p, "ARRAY_ELEMENT", _, c). +// kr_slot(X, F, K): X holds the collection registry F keeps under key expression K +kr_slot(x, f, k) :- kr_yields(x, e), kr_read(e, f, k). +kr_slot(x, f, k) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_slot(i, f, k). + +// ── the store: (method, key parameter position, function parameter position, field) ── +kr_adds("add"). +kr_adds("push"). +kr_adds("unshift"). +kr_store(s, kp, fp, f) :- call_site("client", _, mn, _, recv, ce, _), kr_adds(mn), recv != "", + kr_slot(recv, f, k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "0", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- call_site("client", _, "set", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "1", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- expr_kind("client", "ASSIGNMENT_EXPRESSION", _, asg), + expr_child("client", asg, "LEFT_OPERAND", _, t), kr_read(t, f, k), kr_param(k, s, kp), + expr_child("client", asg, "RIGHT_OPERAND", _, a), kr_param(a, s, fp). + +// ── the dispatch: a method calls what the registry holds under a key ───────── +// kr_elem_holder(H, F, K): the variable or parameter H is bound to an element of the slot +kr_elem_holder(v, f, k) :- for_binding_iterable(v, it), kr_slot(it, f, k). +kr_elem_holder(p, f, k) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +kr_elem_holder(p, f, k) :- call_site("client", "OPTIONAL_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +// (method that reads the key, field, key expression) — a call is made through the element +kr_runs(d, f, k) :- kr_elem_holder(h, f, k), called_through(_, h), kr_read(e, f, k), expr_enclosing_method(e, d). +// the key is a parameter: whoever calls its function names the key +kr_runs_param(owner, pos, f) :- kr_runs(_, f, k), kr_param(k, owner, pos). +kr_dispatched(f) :- kr_runs(_, f, _). +// the key is a constant the dispatch also looks up (`this.F.get(ANY)`) +kr_demand(k) :- kr_runs(_, _, k), !kr_param(k, _, _). +kr_dispatch_lit(d, f, v) :- kr_runs(d, f, k), !kr_param(k, _, _), kr_str(k, v). + +// ── through wrappers ───────────────────────────────────────────────────────── +kr_site_runs(ce, m) :- expr_resolves_to_method(ce, m). +kr_site_runs(ce, m) :- call_runs_method(ce, m). +// kr_disp(W, Pos, F, D): calling W with a key at Pos runs, in D, what F holds under it +kr_disp(o, pos, f, d) :- kr_runs_param(o, pos, f), kr_runs(d, f, k), kr_param(k, o, pos). +kr_disp(w, pos, f, d) :- kr_disp(w2, p2, f, d), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), w != w2. +// kr_reg(W, KeyPos, FnPos, F): calling W stores the function at FnPos in F under the key at KeyPos +kr_reg(s, kp, fp, f) :- kr_store(s, kp, fp, f). +kr_reg(w, kp2, fp2, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), kr_param(ka, w, kp2), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_param(fa, w, fp2), w != s. +// kr_table(W, Pos, F): W stores each entry of the object at Pos under the entry's key: +// the function it stores is `table[k]` of that parameter, directly or through a const +kr_table_elem(x, w, tp) :- expr_kind("client", "ELEMENT_ACCESS", _, x), expr_child("client", x, "RECEIVER", _, r), + kr_param(r, w, tp). +kr_table_elem(x, w, tp) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_table_elem(i, w, tp). +kr_table(w, tp, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), !expr_kind("client", "LITERAL", _, ka), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_table_elem(fa, w, tp). + +// ── the string a key expression holds (on demand) ──────────────────────────── +.decl kr_demand(c0:symbol) +.decl kr_str(c0:symbol, c1:symbol) +.decl kr_denotes_var(c0:symbol, c1:symbol) +.decl kr_objlit(c0:symbol, c1:symbol) +.decl kr_obj(c0:symbol, c1:symbol) +.decl kr_member(c0:symbol, c1:symbol) +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "VARIABLE", v, e). +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "IMPORT_BINDING", ih, e), import_binds(ih, "client", "VARIABLE", v). +kr_str(e, v) :- kr_demand(e), expr_kind("client", "LITERAL", _, e), expr_literal_type("client", "STRING", e), + expr_literal_value("client", v, e). +// a const holding it +kr_demand(i) :- kr_denotes_var(_, v), var_initializer("client", _, i, v), i != "". +kr_str(e, s) :- kr_denotes_var(e, v), var_initializer("client", _, i, v), i != "", kr_str(i, s). +// `x as const`, `x!` +kr_demand(c) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r). +kr_str(e, s) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r), kr_str(c, s). +// `TOPIC.created` over `const TOPIC = { created: '…' } as const` +kr_objlit(i, i) :- expr_kind("client", "OBJECT_LITERAL", _, i). +kr_objlit(i, l) :- expr_kind("client", k, _, i), expr_kind_is_transparent(k), expr_child("client", i, r, _, c), + edge_role_is_operand(r), kr_objlit(c, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "VARIABLE", v, q), var_initializer("client", _, i, v), kr_objlit(i, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "IMPORT_BINDING", ih, q), import_binds(ih, "client", "VARIABLE", v), + var_initializer("client", _, i, v), kr_objlit(i, l). +kr_member(e, val) :- kr_demand(e), property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), kr_obj(q, l), + expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), expr_literal_value("client", n, kk), + expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, val). +kr_demand(val) :- kr_member(_, val). +kr_str(e, s) :- kr_member(e, val), kr_str(val, s). + +// ── the key a call writes ──────────────────────────────────────────────────── +kr_key_arg(a) :- kr_reg(s, kp, _, _), kr_site_runs(oc, s), expr_child("client", oc, "ARGUMENT", kp, a). +kr_key_arg(a) :- kr_disp(w, pos, _, _), kr_site_runs(oc, w), expr_child("client", oc, "ARGUMENT", pos, a). +kr_demand(a) :- kr_key_arg(a). +kr_keyed(a) :- kr_key_arg(a), kr_str(a, _). +kr_keyval(a, v) :- kr_key_arg(a), kr_str(a, v). +kr_keyval(a, "{}") :- kr_key_arg(a), !kr_keyed(a). + +// the function an argument hands over where it is written: a function, an arrow, an import, +// `this.onX`, `onX.bind(this)`, or a const holding one. A parameter passed on is not: the +// wrapper that passes it is followed instead (kr_reg), so its callers are the registrations. +kr_fn(x, m) :- expr_callable(x, m), !expr_kind("client", "CALL_EXPRESSION", _, x). +kr_fn(x, m) :- method_value(x, m). +kr_fn(x, m) :- expr_referenced("client", "VARIABLE", v, x), holder_holds_function(v, m), !kr_table_elem(x, _, _). +// (`const fn = table[name]` inside a wrapper that files each entry is a table hop, kr_table, not a +// registration of every entry under a key only known at run time) + +// a call inside a wrapper that passes its own parameter on is a hop of the wrapper, not a site +kr_forwards(oc) :- kr_disp(w2, p2, _, _), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), kr_disp(w, pos, _, _). + +// ── registrations: (field, key, function) ──────────────────────────────────── +kr_registers(f, kv, m) :- kr_reg(s, kp, fp, f), kr_site_runs(rc, s), + expr_child("client", rc, "ARGUMENT", kp, ka), kr_keyval(ka, kv), + expr_child("client", rc, "ARGUMENT", fp, fa), kr_fn(fa, m). +// a table: each entry of the object literal passed (or the const it initialises) +kr_table_lit(l) :- kr_table(w, tp, _), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l). +.decl value_branch_lit(c0:symbol, c1:symbol) +value_branch_lit(a, a) :- expr_kind("client", "OBJECT_LITERAL", _, a). +value_branch_lit(a, l) :- expr_referenced("client", "VARIABLE", v, a), var_initializer("client", _, l, v), + expr_kind("client", "OBJECT_LITERAL", _, l). +kr_entry(l, pos, v) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, v). +kr_static_key(l, pos) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, _). +kr_entry_key(v, kv) :- kr_entry(l, pos, v), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), + expr_literal_value("client", kv, kk). +// A COMPUTED key (`[TOPIC.created]: fn`) is its own root in the IR, with no link to its property +// (the walker reaches it as COMPUTED_PROPERTY_NAME). It is the last computed-name root of the same +// module that starts inside the literal and ends before the entry's value begins. +kr_at(e, to_number(sl) * 100000 + to_number(sc), to_number(el) * 100000 + to_number(ec)) :- + expr_location("client", sl, sc, el, ec, e), (kr_table_lit(e) ; kr_entry(_, _, e) ; kr_computed_root(e, _)). +kr_computed_root(c, mod) :- expr_root_context("client", "COMPUTED_PROPERTY_NAME", c), expr_kind("client", _, "ROOT", c), + expr_module("client", mod, c). +kr_key_candidate(v, c, cs) :- kr_entry(l, pos, v), !kr_static_key(l, pos), expr_module("client", mod, l), + kr_computed_root(c, mod), kr_at(l, ls, le), kr_at(c, cs, ce), kr_at(v, vs, _), + cs > ls, ce <= vs, vs < le. +kr_entry_key(v, kv) :- kr_key_candidate(v, c, cs), cs = max s : { kr_key_candidate(v, _, s) }, + kr_str(c, kv). +kr_demand(c) :- kr_key_candidate(_, c, _). +kr_registers(f, kv, m) :- kr_table(w, tp, f), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l), kr_entry(l, _, v), kr_entry_key(v, kv), kr_fn(v, m). + +// ── the join ───────────────────────────────────────────────────────────────── +kr_dispatch_site(dc, from, f, d, kv) :- kr_disp(w, pos, f, d), kr_site_runs(dc, w), !kr_forwards(dc), + expr_child("client", dc, "ARGUMENT", pos, a), kr_keyval(a, kv), call_from(dc, from). +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = kr. +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr_dispatch_lit(d, f, kr). +keyed_dispatch(dc, m) :- kr_dispatch_site(dc, _, f, d, kd), kr_registers(f, kr, m), kr_match(kd, kr, f, d). + +call_chain_edge(dc, caller, "-", m, "client", "event_dispatch", kind) :- keyed_dispatch(dc, m), + method_prov(m, "client"), call_from(dc, caller), caller != m, invocation_site(dc, kind). + +// the store's function parameter, in a registry this file dispatches: the envelope row for it +// is withdrawn (value-flow.dl, typed_holder_signature) — on the method, on each overload signature +// a call selects instead of it, and on a wrapper that passes its own parameter on +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), param_decl("client", _, fp, _, s, p). +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), group_implementation(g, s), method_group_of(sig, g), + param_decl("client", _, fp, _, sig, p). + +// A registry's slot is a collection: `slot.add(fn)`, `slot.delete(fn)` store or drop the function and +// never call it, as for a Map or Set the types name (value-flow.dl, collection_store_site). Without +// the library staged the slot has no type, and every function ever subscribed was "handed" to the +// store and reached from every caller of subscribe. +collection_store_site(ce) :- call_site("client", _, mn, _, recv, ce, _), recv != "", mn != "forEach", kr_slot(recv, _, _). diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 442303de..496892d3 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -406,7 +406,10 @@ ref_call_signature(r, sig) :- ref_type_target(r, _, t), call_signature_in_scope( typed_holder_signature(h, sig) :- field_type_ref("client", r, h), ref_call_signature(r, sig). typed_holder_signature(h, sig) :- var_type_ref("client", r, h), ref_call_signature(r, sig). -typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig). +// …except the function parameter of a method that files it in a keyed registry a dispatch reads: +// what runs it is the publish on the same key (call-edge-generation/keyed_registry.dl), and the +// type would hand it to every call of that type, whatever the key +typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig), !kr_registry_param(h). method_dispatch_candidate(sig, m, "value") :- typed_holder_signature(h, sig), holder_holds_function(h, m), diff --git a/tests/cases/typescript/keyed-callback-registry/case.json b/tests/cases/typescript/keyed-callback-registry/case.json new file mode 100644 index 00000000..56538eae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/case.json @@ -0,0 +1,46 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a callback stored in a Map under a key is run by the publish on the same key: the publisher reaches it through the registry, the key read through a const-object member", + "run": ["path", "create", "onCreated"], + "want": ["1 of 1 target(s) reached", "onCreated"], + "avoid": ["no chain of resolved calls"]}, + {"why": "a handler registered under the wildcard the dispatch also looks up runs for every key", + "run": ["path", "create", "onAnything"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a handler registered under another key is not run by that publish", + "run": ["path", "create", "onRemoved"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a handler table handed to a method that subscribes each entry under its own key: the entry under the published key is reached", + "run": ["path", "create", "Indexer.indexed"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the table entry under another key is not", + "run": ["path", "create", "Indexer.dropped"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a key only known at run time may be any key: every handler of that registry is reached", + "run": ["path", "relay", "onRemoved"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a method that reads the registry by key but never calls what it finds reaches no handler", + "run": ["path", "size", "onCreated"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "the terse form: the set created inline in the store, the lookup iterated directly and through forEach on a literal wildcard", + "run": ["path", "go", "onA"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "and the wildcard registration of the terse form", + "run": ["path", "go", "onAll"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the terse form's other key", + "run": ["path", "go", "onB"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "test-impact crosses the registry: the test that publishes the key is selected for the handler", + "run": ["impact", "onCreated", "--tests-only"], + "want": ["app.test.ts"]} + ]} diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.test.ts b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts new file mode 100644 index 00000000..f05f678a --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts @@ -0,0 +1,10 @@ +import { create, remove } from './app'; + +describe('bus', () => { + it('creates', () => { + create(); + }); + it('removes', () => { + remove(); + }); +}); diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.ts b/tests/cases/typescript/keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/bus.ts b/tests/cases/typescript/keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..ee7fad81 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/bus.ts @@ -0,0 +1,59 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: typeof ANY, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/topics.ts b/tests/cases/typescript/keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const;