Browse Source

feat(extraction): Flutter setState→build synthesis + Dart method body ranges

Two changes that connect Flutter's reactive dispatch:

- Dart method ranges (foundational): Dart models a method body as a SIBLING
  of the method_signature node, so every Dart method node had endLine ==
  startLine (signature only) — body-level analysis (callees, context slices,
  the synthesizer's body scan) saw only `void f() {`. Extend endLine to the
  resolved body in the shared createNode, guarded to only ever extend
  (child-body grammars are a no-op; controls excalidraw 9,290 / django 302
  unchanged).
- Flutter setState→build synthesizer channel (the Dart analog of react-render):
  for each Dart class with a `build` method, link sibling methods whose body
  calls setState( → build. setState re-runs build (Flutter-internal, no static
  edge), so "tap → handler → setState → rebuilt UI" dead-ended at setState.

counter initState→build, books build→BookDetail/BookForm. Widget composition
needs no synthesis — Dart widgets are explicit constructor calls, already
static (compass_app build→ErrorIndicator/HomeButton). Tests: Dart method
spans its body; Flutter handler→build synthesis end-to-end. Suite green (798).

Frontier: MVVM Command/ChangeNotifier dispatch (no setState) + Navigator.push
route-as-widget navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 month ago
parent
commit
63728675c1

+ 5 - 0
__tests__/extraction.test.ts

@@ -1151,6 +1151,11 @@ class UserService {
     const privateMethod = methodNodes.find((m) => m.name === '_privateMethod');
     const privateMethod = methodNodes.find((m) => m.name === '_privateMethod');
     expect(privateMethod).toBeDefined();
     expect(privateMethod).toBeDefined();
     expect(privateMethod?.visibility).toBe('private');
     expect(privateMethod?.visibility).toBe('private');
+
+    // Dart models a method body as a SIBLING of the signature, so the method
+    // node must be extended to span its body (not just the signature line) —
+    // required for body-level analysis (callees, the callback synthesizer).
+    expect(findById!.endLine).toBeGreaterThan(findById!.startLine);
   });
   });
 
 
   it('should extract top-level function declarations', () => {
   it('should extract top-level function declarations', () => {

+ 49 - 0
__tests__/frameworks-integration.test.ts

@@ -104,3 +104,52 @@ describe('Flask end-to-end framework extraction', () => {
     cg.close();
     cg.close();
   });
   });
 });
 });
+
+describe('Flutter end-to-end — setState→build synthesis', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('synthesizes a handler→build edge when a State method calls setState', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flutter-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'main.dart'),
+      'import "package:flutter/material.dart";\n' +
+        'class CounterPage extends StatefulWidget {\n' +
+        '  @override\n' +
+        '  State<CounterPage> createState() => _CounterPageState();\n' +
+        '}\n' +
+        'class _CounterPageState extends State<CounterPage> {\n' +
+        '  int _count = 0;\n' +
+        '  void _increment() {\n' +
+        '    setState(() {\n' +
+        '      _count++;\n' +
+        '    });\n' +
+        '  }\n' +
+        '  @override\n' +
+        '  Widget build(BuildContext context) {\n' +
+        '    return Text("$_count");\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const increment = methods.find((n) => n.name === '_increment');
+    const build = methods.find((n) => n.name === 'build');
+    expect(increment).toBeDefined();
+    expect(build).toBeDefined();
+
+    // setState re-runs build (Flutter-internal, no static edge). The synthesizer
+    // bridges the handler → build so the "tap → setState → rebuilt UI" flow connects.
+    const edges = cg.getOutgoingEdges(increment!.id);
+    const toBuild = edges.find((e) => e.target === build!.id && e.kind === 'calls');
+    expect(toBuild, '_increment should reach build via setState synthesis').toBeDefined();
+
+    cg.close();
+  });
+});

+ 15 - 1
src/extraction/tree-sitter.ts

@@ -412,6 +412,20 @@ export class TreeSitterExtractor {
 
 
     const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1);
     const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1);
 
 
+    // Some grammars (e.g. Dart) model a function/method body as a *sibling* of
+    // the signature node, so the declaration node's own range is just the
+    // signature line. Extend endLine to the resolved body when it sits beyond
+    // the node so the node spans its body — required for any body-level analysis
+    // (callees, the callback synthesizer's body scan, context slices). Guarded to
+    // only ever extend: for child-body grammars the body is within range (no-op).
+    let endLine = node.endPosition.row + 1;
+    if (kind === 'function' || kind === 'method') {
+      const body = this.extractor?.resolveBody?.(node, this.extractor.bodyField);
+      if (body && body.endPosition.row + 1 > endLine) {
+        endLine = body.endPosition.row + 1;
+      }
+    }
+
     const newNode: Node = {
     const newNode: Node = {
       id,
       id,
       kind,
       kind,
@@ -420,7 +434,7 @@ export class TreeSitterExtractor {
       filePath: this.filePath,
       filePath: this.filePath,
       language: this.language,
       language: this.language,
       startLine: node.startPosition.row + 1,
       startLine: node.startPosition.row + 1,
-      endLine: node.endPosition.row + 1,
+      endLine,
       startColumn: node.startPosition.column,
       startColumn: node.startPosition.column,
       endColumn: node.endPosition.column,
       endColumn: node.endPosition.column,
       updatedAt: Date.now(),
       updatedAt: Date.now(),

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

@@ -33,6 +33,7 @@ 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 ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
 const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
 const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
 const SETSTATE_RE = /this\.setState\s*\(/;
 const SETSTATE_RE = /this\.setState\s*\(/;
+const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
 const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
 const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
 const MAX_JSX_CHILDREN = 30;
 const MAX_JSX_CHILDREN = 30;
 // Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
 // Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
@@ -238,6 +239,45 @@ function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
   return edges;
   return edges;
 }
 }
 
 
+/**
+ * Phase 4b: Flutter setState → build (the Dart analog of react-render). In a
+ * StatefulWidget's State class, `setState(() {…})` re-runs `build(context)`, but
+ * that hop is framework-internal (Flutter calls build), so a flow like
+ * "onPressed → _increment → setState → rebuilt UI" dead-ends at setState. Bridge
+ * it: for each Dart class with a `build` method, link every sibling method whose
+ * body calls `setState(` → `build`. The setState gate + `.dart` file keep this to
+ * Flutter State classes. Over-approximation accepted (reachability-correct).
+ */
+function flutterBuildEdges(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 build = children.find((n) => n.name === 'build');
+    if (!build || !build.filePath.endsWith('.dart')) continue;
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.id === build.id) continue;
+      const content = ctx.readFile(m.filePath);
+      const src = content && sliceLines(content, m.startLine, m.endLine);
+      if (!src || !FLUTTER_SETSTATE_RE.test(src)) continue;
+      const key = `${m.id}>${build.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: m.id, target: build.id, kind: 'calls', line: m.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'flutter-build', via: 'setState', registeredAt: `${build.filePath}:${build.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
 /**
 /**
  * Phase 5: React JSX child rendering. A component that returns `<Child .../>`
  * 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,
  * mounts Child — React calls it — but JSX instantiation isn't a static call edge,
@@ -383,10 +423,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const renderEdges = reactRenderEdges(queries, ctx);
   const renderEdges = reactRenderEdges(queries, ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
   const vueEdges = vueTemplateEdges(ctx);
   const vueEdges = vueTemplateEdges(ctx);
+  const flutterEdges = flutterBuildEdges(queries, ctx);
 
 
   const merged: Edge[] = [];
   const merged: Edge[] = [];
   const seen = new Set<string>();
   const seen = new Set<string>();
-  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges]) {
+  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges]) {
     const key = `${e.source}>${e.target}`;
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
     if (seen.has(key)) continue;
     seen.add(key);
     seen.add(key);