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
+156 -1
View File
@@ -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 ~3080, 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;