1
0
Эх сурвалжийг харах

feat(resolution): bridge React boundaries — re-render + JSX child synthesis

Closes the two dynamic-dispatch hops that broke "state mutation -> on-screen
render" flows in React apps. Both are call-invisible (React-internal) but the
code between them is fully call-connected, so one synthesized edge each makes the
whole flow trace end-to-end.

- reactRenderEdges: setState(...) re-runs the component's render(). For each
  class with a render method, link sibling methods calling this.setState ->
  render. The setState gate keeps it to React class components.
- reactJsxChildEdges: a component that returns <Child .../> mounts Child. Link
  parent -> each capitalized JSX child, resolved to a component/function/class
  node (the resolution gate drops TS generics like Array<Foo>). File-oriented,
  capped per parent.
- Surface both in synthEdgeNote (trace + node trail) and context call-paths.

Validated on excalidraw: trace(mutateElement, renderStaticScene) now connects in
6 hops across callback -> react-render -> jsx-child; 1 + 46 + 280 synthesized
edges, node count stable (no explosion). Partial coverage is worse than none:
react-render alone raised agent reads (revealed a hop it then drilled); adding
the jsx hop closed the flow and dropped reads to 0-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 сар өмнө
parent
commit
f39aee2157

+ 4 - 0
src/context/index.ts

@@ -344,6 +344,10 @@ export class ContextBuilder {
       const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
       const label = m.synthesizedBy === 'callback'
         ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}`
+        : m.synthesizedBy === 'react-render'
+        ? `React re-render via setState${at}`
+        : m.synthesizedBy === 'jsx-render'
+        ? `renders <${String(m.via || 'child')}>`
         : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
       synthByPair.set(`${e.source}>${e.target}`, label);
     }

+ 15 - 0
src/mcp/tools.ts

@@ -1102,6 +1102,21 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'react-render') {
+      return {
+        label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
+        compact: `dynamic: React re-render via setState${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'jsx-render') {
+      const child = m.via ? `<${String(m.via)}>` : 'a child component';
+      return {
+        label: `renders ${child} (JSX child — dynamic dispatch)`,
+        compact: `dynamic: renders ${child}`,
+        registeredAt,
+      };
+    }
     return null;
   }
 

+ 96 - 3
src/resolution/callback-synthesizer.ts

@@ -32,6 +32,9 @@ const EVENT_FANOUT_CAP = 6; // skip events with more handlers/dispatchers than t
 
 const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
 const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
+const SETSTATE_RE = /this\.setState\s*\(/;
+const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
+const MAX_JSX_CHILDREN = 30;
 
 function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
   if (!startLine || !endLine) return null;
@@ -183,16 +186,106 @@ function eventEmitterEdges(ctx: ResolutionContext): Edge[] {
 }
 
 /**
- * Synthesize dispatcher→callback edges (field observers + EventEmitters).
- * Returns the count added. Never throws into indexing — callers wrap in try/catch.
+ * Phase 4: React class-component re-render. `this.setState(...)` re-runs the
+ * component's `render()`, but that hop is React-internal — no static edge — so a
+ * flow like "mutation → setState → canvas repaint" dead-ends at setState even
+ * though `render → getRenderableElements → …` is fully call-connected after it.
+ * Bridge it: for each class that has a `render` method, link every sibling method
+ * whose body calls `this.setState(` → `render`. The setState gate keeps this to
+ * React class components (a non-React class with a `render` method won't call
+ * `this.setState`). Over-approximation (all setState methods reach render) is
+ * accepted — it's reachability-correct, like the callback channels.
+ */
+function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const cls of queries.getNodesByKind('class')) {
+    const children = queries.getOutgoingEdges(cls.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+    const render = children.find((n) => n.name === 'render');
+    if (!render) continue;
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.id === render.id) continue;
+      const content = ctx.readFile(m.filePath);
+      const src = content && sliceLines(content, m.startLine, m.endLine);
+      if (!src || !SETSTATE_RE.test(src)) continue;
+      const key = `${m.id}>${render.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: m.id, target: render.id, kind: 'calls', line: m.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'react-render', via: 'setState', registeredAt: `${render.filePath}:${render.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+/**
+ * Phase 5: React JSX child rendering. A component that returns `<Child .../>`
+ * mounts Child — React calls it — but JSX instantiation isn't a static call edge,
+ * so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
+ * JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
+ * (read each JSX file once). Precision gate: the child name must resolve to a
+ * component/function/class node — TS generics like `Array<Foo>` resolve to a type
+ * (or nothing) and are dropped.
+ */
+function reactJsxChildEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const PARENT_KINDS = new Set(['method', 'function', 'component']);
+  for (const file of ctx.getAllFiles()) {
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('</') && !content.includes('/>'))) continue; // JSX-file gate
+    const parents = ctx.getNodesInFile(file).filter((n) => PARENT_KINDS.has(n.kind));
+    for (const parent of parents) {
+      const src = sliceLines(content, parent.startLine, parent.endLine);
+      if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
+      const names = new Set<string>();
+      JSX_TAG_RE.lastIndex = 0;
+      let m: RegExpExecArray | null;
+      while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
+      let added = 0;
+      for (const name of names) {
+        if (added >= MAX_JSX_CHILDREN) break;
+        const child = ctx.getNodesByName(name).find(
+          (n) => n.kind === 'component' || n.kind === 'function' || n.kind === 'class'
+        );
+        if (!child || child.id === parent.id) continue;
+        const key = `${parent.id}>${child.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: parent.id, target: child.id, kind: 'calls', line: parent.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'jsx-render', via: name },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Synthesize dispatcher→callback edges (field observers + EventEmitters +
+ * React re-render + JSX children). Returns the count added. Never throws into
+ * indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
   const fieldEdges = fieldChannelEdges(queries, ctx);
   const emitterEdges = eventEmitterEdges(ctx);
+  const renderEdges = reactRenderEdges(queries, ctx);
+  const jsxEdges = reactJsxChildEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
-  for (const e of [...fieldEdges, ...emitterEdges]) {
+  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
     seen.add(key);