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>
This commit is contained in:
Colby McHenry
2026-06-20 15:17:31 -05:00
co-authored by Claude Opus 4.8
parent 270e50655a
commit 7f970296cf
3 changed files with 240 additions and 2 deletions
@@ -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 classs 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);
});
});