Ver código fonte

feat(resolution): synthesize object-literal registry dispatch edges

Adds `objectRegistryEdges` — a dynamic-dispatch synthesizer for the command/handler
registry pattern: an object literal maps string keys → handler classes/functions, then
dispatches by a RUNTIME key static parsing can't follow:

    this.commands = { [Cmd.ADD]: AddObjectCommand, ... }    // registration
    new this.commands[command](args).execute()              // dynamic dispatch

It links each dispatching function → each registered handler's callable entry (a class's
execute/run/handle method — preferring the method chained at the dispatch site — or the
function value), like the gin-middleware-chain fan-out. Same-file registry+dispatch only.

Validated precise on 3 real repos (the discipline that caught redux-thunk's n=1 overfit):
EtherealEngine's CommandManager (64 edges, class registry → .execute), Prebid.js (7:
builder/consent/message dispatch, function registry), warp-drive (1). Zero false positives
after several precision gates found during validation:
- skip minified/generated bundles (avg line length > 200) — draco/three.min were a
  false-positive minefield of `h[x](...)` calls + `{a:b}` literals;
- DEPTH-AWARE entry parsing (top-level `key: Identifier` only) so method-shorthand bodies
  and nested objects don't leak their inner `k: v` pairs as bogus handlers;
- callable-only targets (drop data `constant`s — a `{x: URL}` entry resolving to the global);
- dynamic-dispatch gate (a statically-accessed look-alike object yields nothing).
Handles constructor and field-initializer registry forms (this. normalized). Surfaces in
codegraph_explore via the existing Dynamic-dispatch-links section.

Deferred (recall, documented in dispatch-synthesizer-backlog.md): assign-then-call dispatch,
augmentation registration (reg[k]=H), and the cross-file barrel-namespace variant
(trezor getMethod) — the hard tier.

Full suite green (1606); new __tests__/object-registry-synthesizer.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Colby McHenry 2 dias atrás
pai
commit
7f970296cf

+ 83 - 0
__tests__/object-registry-synthesizer.test.ts

@@ -0,0 +1,83 @@
+/**
+ * Object-literal registry dispatch synthesizer.
+ *
+ * A command registry maps keys → handler classes/functions in an object literal, then
+ * dispatches by a RUNTIME key (`new registry[command]().execute()`) that static parsing
+ * can't follow. The synthesizer links each dispatching method → each registered handler's
+ * callable entry. Validates: a class registry resolves to the handler's `.execute` method;
+ * the field-initializer form (`commands = {…}` matched against a `this.commands[k]` dispatch);
+ * and the dispatch GATE — a look-alike object literal that is only ever accessed statically
+ * (never `registry[var]`) yields no edges.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('object-registry synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'obj-registry-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('links a dispatcher to each registered command class’s execute method, gated on dynamic dispatch', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'commands.ts'),
+      `export class AddCommand { execute() { return 'add'; } }
+export class RemoveCommand { execute() { return 'remove'; } }
+export class MoveCommand { execute() { return 'move'; } }
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'manager.ts'),
+      `import { AddCommand, RemoveCommand, MoveCommand } from './commands';
+
+const Cmd = { ADD: 'add', REMOVE: 'remove', MOVE: 'move' };
+
+class CommandManager {
+  commands = {
+    [Cmd.ADD]: AddCommand,
+    [Cmd.REMOVE]: RemoveCommand,
+    [Cmd.MOVE]: MoveCommand,
+  };
+
+  executeCommand(command: string) {
+    return new this.commands[command]().execute();
+  }
+}
+`
+    );
+    // A look-alike registry that is NEVER dynamically dispatched (only a static `.add`
+    // member access) — must yield NO edges. The dynamic `registry[var]` dispatch is the gate.
+    fs.writeFileSync(
+      path.join(dir, 'static.ts'),
+      `import { AddCommand, RemoveCommand } from './commands';
+const table = { add: AddCommand, remove: RemoveCommand };
+export function direct() { return new table.add().execute(); }
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file
+         FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'object-registry'`
+      )
+      .all();
+    cg.close?.();
+
+    // Exactly the 3 dispatcher→handler-entry edges: executeCommand → {Add,Remove,Move}Command.execute.
+    expect(rows.length).toBe(3);
+    expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true);
+    expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true);
+    expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true);
+    // The statically-accessed look-alike registry contributed nothing.
+    expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false);
+  });
+});

+ 1 - 1
docs/design/dispatch-synthesizer-backlog.md

@@ -54,7 +54,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
 
 | Shape | Ecosystem | The static anchor that bridges it | Mechanism | Status |
 |---|---|---|---|---|
-| **Name→class registry / command bus** | any (TS, .NET, Java, Go…) | a registry `{key: Class}` (object literal *or* module-namespace export) + a call naming `key`; bridge `key → Class.run/handle` by literal-key match, else camel↔Pascal convention | S (fan-out + name-match) | 🔬 **the most generalizable one.** trezor `getMethod` (`methods[method]→new MethodConstructor`), n8n node registry, VS Code commands, webpack loaders. **Hard sub-case:** trezor resolves via *dynamic import of a computed path* + runtime-string index + **case transform** (`'signTransaction'`→`SignTransaction`) — a fan-out (dispatcher→all registered classes) gated by name-match, like `gin-middleware-chain`. |
+| **Name→class registry / command bus** | any (TS/JS first) | object-literal registry `{key: Handler}` + computed-key dispatch `(new) reg[var](…)` | S (fan-out, `object-registry`) | ✅ **SHIPPED v1 (2026-06-20)** — `objectRegistryEdges`. Links each dispatcher fn → each registered handler's callable entry (a class's `execute`/run/handle method — preferring the method chained at the dispatch — or the function value). Precise on **xrengine** (CommandManager, 64 edges, class registry → `.execute`), **Prebid.js** (7: builder/consent/message dispatch, fn registry), **warp-drive** (1). **0 false positives** after: minified-file skip (avg line >200), **depth-aware** entry parse (top-level `key: Ident` only — method-shorthand/nested-object bodies don't leak), callable-only targets (no data `constant`), dynamic-dispatch gate. Handles constructor + field-initializer (`this.` normalized) forms. **Deferred (recall, documented):** assign-then-call (`const h=reg[k]; h()` — warp-drive's main `COMMANDS`), augmentation (`reg[k]=H` — Prebid single-entry), method-shorthand entry recall, and the **cross-file barrel-namespace** variant (trezor `getMethod`: `import * as M; M[method]→new` + computed dynamic import + camel↔Pascal — the hard tier, still 🔬). |
 | **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX`↔`useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | 🔬 **found on shapeshift** (14 `createApi` files, currently invisible). The modern RTK default — likely higher traffic than hand thunks now. |
 | **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced) | S (string-keyed, like `event-emitter`) | ⬜ |
 | **NgRx effects** | Angular | `createEffect(() => actions.pipe(ofType(LoginAction), …))` → effect handler; `Store.dispatch(new LoginAction())` → effect by action type/class | S (type/class-keyed) | ⬜ |

+ 156 - 1
src/resolution/callback-synthesizer.ts

@@ -1714,11 +1714,164 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
   return edges;
 }
 
+// ── Object-literal registry dispatch ─────────────────────────────────────────
+// A command/handler registry maps string keys → handler class/function symbols in an
+// object literal, then dispatches by a RUNTIME key static parsing can't follow:
+//   this.commands = { [Cmd.ADD]: AddObjectCommand, ... }    // registration
+//   new this.commands[command](args).execute()              // dynamic dispatch
+// Bridge it like gin-middleware-chain: link each dispatching function → each registered
+// handler's callable entry (a class's execute/run/handle/… method — preferring the method
+// chained at the dispatch site — or the function value). Scoped to a registry + dispatch in
+// the SAME file (the cross-file barrel-namespace variant, e.g. trezor's getMethod, is
+// deferred). Gated on a real object literal with ≥2 entries that RESOLVE to callables (a
+// `{ width: 5 }` literal resolves to nothing → no edges); fan-out capped.
+const REGISTRY_ASSIGN_RE = /(?:(?:const|let|var)\s+([A-Za-z_$][\w$]*)|((?:this\.)?[A-Za-z_$][\w$]*))\s*=\s*\{/g;
+const REGISTRY_DISPATCH_RE = /(?:\bnew\s+)?((?:this\.)?[A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$.]*)\s*\]\s*(?:\(|\.[A-Za-z_$])/g;
+const REGISTRY_MIN_ENTRIES = 2;
+const REGISTRY_FANOUT_CAP = 40;
+const REGISTRY_CLASS_ENTRY = new Set(['execute', 'run', 'handle', 'perform', 'process', 'call', 'apply', 'dispatch']);
+const REGISTRY_JS_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
+
+/** From the index of an opening `{`, return the brace-balanced body up to its matching `}`. */
+function braceBody(src: string, openIdx: number): string | null {
+  let depth = 0;
+  for (let i = openIdx; i < src.length; i++) {
+    if (src[i] === '{') depth++;
+    else if (src[i] === '}' && --depth === 0) return src.slice(openIdx + 1, i);
+  }
+  return null;
+}
+
+/** Top-level `key: Identifier` entries of an object-literal body. DEPTH-AWARE: only depth-0
+ *  segments are considered, so method-shorthand bodies (`number(a,b){…}`), arrow values
+ *  (`x: () => …`), and nested objects (`x: { … }`) don't leak their inner `k: v` pairs as
+ *  bogus handlers. The per-segment anchor (`^… key: Ident …$`) keeps only pure identifier
+ *  values — a data value (`x: 5`), call, or arrow fails to match. */
+function registryEntryNames(body: string): string[] {
+  const segs: string[] = [];
+  let depth = 0;
+  let start = 0;
+  for (let i = 0; i < body.length; i++) {
+    const c = body[i];
+    if (c === '{' || c === '(' || c === '[') depth++;
+    else if (c === '}' || c === ')' || c === ']') depth--;
+    else if (c === ',' && depth === 0) { segs.push(body.slice(start, i)); start = i + 1; }
+  }
+  segs.push(body.slice(start));
+  const names: string[] = [];
+  for (const seg of segs) {
+    const m = /^\s*(?:\[[^\]]+\]|['"]?[\w$]+['"]?)\s*:\s*([A-Za-z_$][\w$]*)\s*$/.exec(seg);
+    if (m && m[1]!.length >= 3 && !names.includes(m[1]!)) names.push(m[1]!);
+  }
+  return names;
+}
+
+/** Resolve a registered handler name to its callable entry: a function value, or a class's
+ *  `execute`-like method (preferring the method chained at the dispatch site), else the class. */
+function resolveRegistryHandler(ctx: ResolutionContext, name: string, chained: string | null): Node | null {
+  const cands = ctx.getNodesByName(name);
+  const fn = cands.find((n) => n.kind === 'function');
+  if (fn) return fn;
+  const cls = cands.find((n) => n.kind === 'class' || n.kind === 'struct');
+  if (cls) {
+    const methods = ctx
+      .getNodesInFile(cls.filePath)
+      .filter((n) => n.kind === 'method' && n.startLine >= cls.startLine && n.startLine <= (cls.endLine ?? cls.startLine));
+    const want = chained && REGISTRY_CLASS_ENTRY.has(chained) ? chained : null;
+    const entry =
+      (want && methods.find((m) => m.name === want)) ||
+      methods.find((m) => REGISTRY_CLASS_ENTRY.has(m.name)) ||
+      methods.find((m) => m.name === 'constructor');
+    return entry ?? cls;
+  }
+  // Require a CALLABLE target — a registry dispatched as `reg[k](…)` invokes a function/
+  // method, never a data `constant` (dropping it removes false positives like a `{ x: URL }`
+  // entry resolving to the global URL constant).
+  return cands.find((n) => n.kind === 'method') ?? null;
+}
+
+function objectRegistryEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!REGISTRY_JS_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    // Cheap pre-filter: a computed member access BY NAME (`ident[ident`) — the dispatch shape.
+    if (!content || !/[\w$]\s*\[\s*[A-Za-z_$]/.test(content)) continue;
+    // Skip minified/generated bundles (draco, three.min, base64…): their pervasive `h[x](...)`
+    // calls + single-letter `{a:b}` literals are a false-positive minefield. Average line
+    // length is the reliable tell — real source ~30–80, minified in the hundreds/thousands.
+    const newlines = (content.match(/\n/g)?.length ?? 0) + 1;
+    if (content.length / newlines > 200) continue;
+    const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+
+    // 1. Dispatch sites: `(new )?<ref>[<ident-key>]` followed by a call or a chained method.
+    //    A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch.
+    REGISTRY_DISPATCH_RE.lastIndex = 0;
+    const dispatches: Array<{ ref: string; line: number; chained: string | null }> = [];
+    let dm: RegExpExecArray | null;
+    while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) {
+      const win = safe.slice(dm.index, dm.index + 160);
+      const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win);
+      dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null });
+    }
+    if (!dispatches.length) continue;
+    // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`)
+    // matches a `this.commands[k]` dispatch, not just the constructor form `this.commands = {…}`.
+    const norm = (r: string) => r.replace(/^this\./, '');
+    const refs = new Set(dispatches.map((d) => norm(d.ref)));
+
+    // 2. Registries: an object literal assigned to a dispatched ref, ≥2 entries resolving to callables.
+    REGISTRY_ASSIGN_RE.lastIndex = 0;
+    const registries = new Map<string, { names: string[]; line: number }>();
+    let am: RegExpExecArray | null;
+    while ((am = REGISTRY_ASSIGN_RE.exec(safe))) {
+      const lhs = norm(am[1] ?? am[2]!);
+      if (!refs.has(lhs) || registries.has(lhs)) continue;
+      const body = braceBody(safe, am.index + am[0].length - 1);
+      if (!body) continue;
+      const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only
+      if (names.length >= REGISTRY_MIN_ENTRIES) {
+        registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length });
+      }
+    }
+    if (!registries.size) continue;
+
+    // 3. Link each dispatcher → each registered handler's callable entry.
+    const nodesInFile = ctx.getNodesInFile(file);
+    for (const d of dispatches) {
+      const reg = registries.get(norm(d.ref));
+      if (!reg) continue;
+      const disp = enclosingFn(nodesInFile, d.line);
+      if (!disp) continue;
+      let added = 0;
+      for (const name of reg.names) {
+        if (added >= REGISTRY_FANOUT_CAP) break;
+        const target = resolveRegistryHandler(ctx, name, d.chained);
+        if (!target || target.id === disp.id) continue;
+        const key = `${disp.id}>${target.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: disp.id,
+          target: target.id,
+          kind: 'calls',
+          line: d.line,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'object-registry', via: name, registeredAt: `${file}:${reg.line}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
  * React re-render + JSX children + Vue templates + SvelteKit load + RN event
  * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain +
- * Redux-thunk dispatch chain).
+ * Redux-thunk dispatch chain + object-literal registry dispatch).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -1757,6 +1910,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
   const thunkEdges = reduxThunkEdges(queries, ctx);
+  const registryEdges = objectRegistryEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -1781,6 +1935,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...mybatisEdges,
     ...ginEdges,
     ...thunkEdges,
+    ...registryEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;