From 4959bddc5b253537edbc52d93a9a7ce4e562c5ff Mon Sep 17 00:00:00 2001 From: I S A I A S <32isaias@gmail.com> Date: Mon, 25 May 2026 21:06:11 -0400 Subject: [PATCH] feat(resolution): add Flutter framework support for Dart projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three Flutter framework resolvers in src/resolution/frameworks/flutter.ts that fill the two known gaps in the playbook's `Dart | Flutter` matrix row (`🔬 Navigator/MaterialApp routes` and `🔬 MVVM Command/ChangeNotifier`), mirroring the swift.ts pattern of grouping multiple co-existing resolvers in one file each with its own detect(). Extends — does not replace — the existing upstream Flutter coverage (`setState→build` synthesizer in callback-synthesizer.ts and the Dart method-range fix). - flutterResolver — core widget framework * Detects via pubspec.yaml `flutter: sdk: flutter` OR any .dart file importing package:flutter/*. * Marks package:flutter/*, package:flutter_*, dart:ui, dart:ui_web as framework-provided (confidence 1.0). * Short-circuits ~140 common Material/Cupertino/Widgets-library built-in widget names so the name resolver doesn't waste cycles searching for user-defined symbols. * Resolves PascalCase user widgets to their class/component nodes, preferring same-file, same-directory, then /lib/widgets/ and /lib/screens/ (0.85). * Pairs `_FooState extends State` to its widget class via the candidates field (0.9). State pattern is checked BEFORE the built-in short-circuit so candidates resolve correctly. * Extracts StatelessWidget/StatefulWidget/ConsumerWidget/HookWidget subclasses as `component` nodes, pairs State classes, and emits a class node for the runApp() root widget. - flutterRouterResolver — Navigator named routes + GoRouter / ShellRoute trees * Detects via go_router in pubspec.yaml OR inline MaterialApp routes maps / GoRoute calls. * Extracts MaterialApp `routes: { '/path': (ctx) => Screen() }` maps. * Extracts GoRoute(path:, builder:) — nested routes join parent paths via a stack-based scanner tracking parenthesis depth. * Handles four real-world router idioms that initial probes against flutter/samples revealed and a literal-only matcher would miss: - Block-body builders: `builder: (c, s) { return Screen(); }` (the dominant form for any non-trivial route — most routes use it). - Generic-typed page wrappers: `pageBuilder: …{ return FadeTransitionPage(child: Screen()); }` — without an optional `<…>` allowance, the regex stops at the bare wrapper name and backtracks into a deeper `return Screen(`, capturing the wrong handler. - Top-level-only param scope: a parent ShellRoute / GoRoute body contains nested `routes: […]` with their own `path:` / `pageBuilder:` — the matcher bounds its scan to the params before the first child `routes: [`, so descendants don't contaminate the parent's path or handler. - Constant-reference paths: `path: Routes.login` (common when apps centralize paths in a `class Routes { static const … }`) — accepted as identifier expressions; the route node carries the identifier as its name so the route → handler edge still materializes. * Resolves route → handler refs preferring /lib/screens/ and /lib/pages/ (0.85). - flutterStateResolver — Provider / Riverpod / Bloc / GetX * Detects each package independently from pubspec.yaml. * claimsReference() opts framework dispatch shapes (context.read, ref.watch, BlocProvider.of, Get.find, etc.) through the pre-filter so they reach resolve() and short-circuit to framework-provided (1.0) instead of being fuzzy-matched against unrelated symbols. * Resolves *Provider (Riverpod), *Bloc/*Cubit (flutter_bloc), and *Controller (GetX) names preferring their conventional directories. Also adds 'dart' to CommentLang in src/resolution/strip-comments.ts so the Flutter extractors can scan content with line/block comments stripped (Dart syntax is C-style, so it routes through stripCStyle with single-quote string support enabled). Validation per CLAUDE.md "Validation methodology (REQUIRED for every new language/framework)": DETERMINISTIC (no API spend) — codified as `scripts/agent-eval/validate-flutter.sh` so it re-runs as a 12-second quality gate: | repo | tier | nodes | routes | components | route→handler edges | |---------------------------------|------|-------|--------|------------|---------------------| | navigation_and_routing | S | 306 | 10/10 | 15 | 8 | | compass_app/app | M | 1873 | 7/7 | 50 | (provider/MVVM) | | expressjs/express (control) | — | 990 | 266 | n/a | n/a (regression-free) | AGENT A/B (Claude Opus headless via Claude Code OAuth, n=2 per arm, `scripts/agent-eval/run-all.sh`): small ("How does navigating to /sign-in show the SignInScreen?"): with codegraph: Read 0-1 / tool-calls 3-4 / 23-26s without: Read 2 / tool-calls 5-8 / 34-44s medium ("Routes.home → BookingScreen + ViewModel via nested GoRoute"): with codegraph: Read 0-1 / tool-calls 3-4 / 24-25s without: Read 4 / tool-calls 8 / 35-47s Aggregate (4 A/Bs, 8 arms): Read 3.0 → 0.5 (-83%), duration 40s → 24.5s (-39%), tool calls -46%. Total spend $1.80. With-arms reliably picked `codegraph_context` + `codegraph_explore` / `codegraph_node`; without-arms ls'd, grep'd, then Read the router + screen + viewmodel files. CHANGELOG.md updated under [Unreleased] → ### Added per the auto-promote workflow. .claude/skills/agent-eval/corpus.json gains Dart entries for the two flutter/samples apps so future /agent-eval picks them up automatically. docs/design/dynamic-dispatch-coverage-playbook.md matrix row reclassified from `S + X` to `R + S + X` (resolver added on top of existing synthesizer + extraction); previous `🔬 MVVM Command/ChangeNotifier` and `🔬 Navigator/MaterialApp routes` gaps are now ✅; new narrower 🔬s logged (cross-symbol ChangeNotifier→listener synthesis, inline anonymous `Navigator.push(MaterialPageRoute(builder:))`). Test coverage in __tests__/frameworks.test.ts (+25 new cases): detect, resolve, extract for each of the three resolvers (incl. block-body, generic wrappers, top-level scope, const-ref paths), registry wiring, and commented-route regression. The existing `__tests__/frameworks-integration.test.ts > Flutter end-to-end — setState→build synthesis` test still passes — no regression to the upstream synthesizer. Verification summary: - npx tsc --noEmit : clean - npm run build : clean (no new SQL/wasm) - npx vitest run __tests__/frameworks.test.ts : 123/123 pass - npx vitest run (full suite) : 1042 pass / 2 skipped - bash scripts/agent-eval/validate-flutter.sh : 11/11 pass - agent A/B (4 runs) : Read -83%, time -39% Closes #420. --- .claude/skills/agent-eval/corpus.json | 2 + CHANGELOG.md | 6 + __tests__/frameworks.test.ts | 558 +++++++++++++ .../dynamic-dispatch-coverage-playbook.md | 2 +- scripts/agent-eval/validate-flutter.sh | 171 ++++ src/resolution/frameworks/flutter.ts | 743 ++++++++++++++++++ src/resolution/frameworks/index.ts | 6 + src/resolution/strip-comments.ts | 6 +- 8 files changed, 1491 insertions(+), 3 deletions(-) create mode 100755 scripts/agent-eval/validate-flutter.sh create mode 100644 src/resolution/frameworks/flutter.ts diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index e81a98ada..669297ce4 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -55,6 +55,8 @@ { "name": "grpc", "repo": "https://github.com/grpc/grpc", "size": "Large", "files": "~3000", "question": "How does gRPC dispatch an incoming RPC to its handler?" } ], "Dart": [ + { "name": "navigation_and_routing", "repo": "https://github.com/flutter/samples", "subdir": "navigation_and_routing", "size": "Small", "files": "~17", "question": "How does navigating to /books/popular show the BooksScreen?" }, + { "name": "compass_app", "repo": "https://github.com/flutter/samples", "subdir": "compass_app/app", "size": "Medium", "files": "~129", "question": "How does the booking flow reach BookingScreen from the home tab?" }, { "name": "flutter", "repo": "https://github.com/flutter/flutter", "size": "Large", "files": "~6000", "question": "How does Flutter build and lay out a widget tree?" } ], "Svelte": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c8c9900..7d6b7d081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The IDE shares `GEMINI.md` with Gemini CLI, so the two targets compose naturally when both are installed; the antigravity target deliberately doesn't touch `GEMINI.md` so uninstalling Antigravity alone leaves CLI instructions intact. Both targets are tested on the same parameterized contract as the existing five agents (idempotent install, sibling preservation, install/uninstall round-trip), with extra coverage for migration-marker detection, legacy → unified entry migration, sibling `disabled` field preservation, and the cross-target case where Gemini CLI and Antigravity IDE coexist in the same `~/.gemini/`. Closes #399. +- **Flutter framework-aware resolution for Dart projects.** Three new resolvers in `src/resolution/frameworks/flutter.ts` recognize Flutter-specific patterns on top of the existing Dart language extraction, so widget trees, navigation, and state-management dispatches resolve end-to-end instead of falling back to generic name matching: + - **`flutterResolver`** — widgets. Detected via `pubspec.yaml`'s `flutter: sdk: flutter` block or any `.dart` file importing `package:flutter/*`. `StatelessWidget`/`StatefulWidget`/`ConsumerWidget`/`HookWidget` subclasses become `component` nodes; `_FooState extends State` is paired back to its widget; `runApp(MyApp())` emits an app entry node. PascalCase user-widget references resolve to their class/component, preferring `/lib/widgets/` and `/lib/screens/`. ~140 common Material / Cupertino / Widgets-library built-ins (`Scaffold`, `Container`, `Row`, `MaterialApp`, …) and `package:flutter/*` / `dart:ui` imports short-circuit as framework-provided (1.0) so the name resolver doesn't waste cycles on them. + - **`flutterRouterResolver`** — navigation. Extracts `MaterialApp(routes: { '/foo': (ctx) => FooScreen() })` named routes and `GoRoute(path:, builder:)` / `ShellRoute(...)` trees (parent-prefix joined via a stack-based scanner). Matches real-world idioms: block-body builders (`builder: (c, s) { return Screen(); }`, the dominant form), generic-typed page wrappers (`pageBuilder: …{ return FadeTransitionPage(child: Screen()); }`), and constant-reference paths (`path: Routes.login` — common when apps centralize paths in a `class Routes`). Route → handler refs resolve to screen classes preferring `/lib/screens/` and `/lib/pages/`. Activates on either `go_router` in `pubspec.yaml` or an inline routes map / GoRoute call. + - **`flutterStateResolver`** — Provider / Riverpod / Bloc / GetX. Per-package detection from `pubspec.yaml`. Framework dispatch shapes (`context.read`, `ref.watch`, `BlocProvider.of`, `Get.find`, …) short-circuit as framework-provided so they don't fuzzy-match to unrelated symbols; `*Provider`, `*Bloc`/`*Cubit`, and `*Controller` names resolve preferring their conventional directories. + + Also adds `'dart'` to the `CommentLang` set in `strip-comments.ts` so the Flutter extractors can scan comment-stripped source (`//` and `/* */`, Dart-flavoured). Validated end-to-end per the dynamic-dispatch-coverage-playbook: deterministic — `flutter/samples/navigation_and_routing` (17 .dart files, 306 nodes) emits all 10 GoRoute paths with correct handlers, `compass_app/app` (129 .dart files, 1873 nodes) emits all 7 GoRoute paths through `Routes.X` constant-reference paths, express control unchanged (990 nodes, 266 routes); agent A/B (n=2 per arm, Opus headless via Claude Code) on the same two repos shows codegraph cuts agent **Read 3.0 → 0.5 (−83%), duration 40s → 24.5s (−39%), tool calls −46%**. Codified as `scripts/agent-eval/validate-flutter.sh` (deterministic half — re-runs as a 12-second quality gate). 25 new test cases in `__tests__/frameworks.test.ts` cover detect/resolve/extract for each resolver including the four real-world router idioms above. Closes #420. ## [0.9.5] - 2026-05-25 diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index c0e874908..aad432109 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1597,3 +1597,561 @@ export class UsersController { expect(references.map((r) => r.referenceName)).toEqual(['real']); }); }); + +// =========================================================================== +// Flutter +// =========================================================================== + +import { + flutterResolver, + flutterRouterResolver, + flutterStateResolver, +} from '../src/resolution/frameworks/flutter'; + +const flutterBaseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], +}; + +describe('flutterResolver.detect', () => { + it('detects a pubspec.yaml declaring the flutter SDK', () => { + const context = { + ...flutterBaseContext, + readFile: (p: string) => + p === 'pubspec.yaml' + ? `name: my_app\ndependencies:\n flutter:\n sdk: flutter\n` + : null, + }; + expect(flutterResolver.detect(context as any)).toBe(true); + }); + + it('detects a .dart file that imports package:flutter when pubspec is absent', () => { + const context = { + ...flutterBaseContext, + getAllFiles: () => ['lib/main.dart'], + readFile: (p: string) => + p === 'lib/main.dart' ? `import 'package:flutter/material.dart';\n` : null, + }; + expect(flutterResolver.detect(context as any)).toBe(true); + }); + + it('returns false for a Dart-only (non-Flutter) project', () => { + const context = { + ...flutterBaseContext, + getAllFiles: () => ['lib/server.dart'], + readFile: (p: string) => { + if (p === 'pubspec.yaml') return `name: server\ndependencies:\n shelf: ^1.0.0\n`; + if (p === 'lib/server.dart') return `import 'package:shelf/shelf.dart';\n`; + return null; + }, + }; + expect(flutterResolver.detect(context as any)).toBe(false); + }); +}); + +describe('flutterResolver.resolve', () => { + it('marks package:flutter/* imports as framework-provided with confidence 1.0', () => { + const ref = { + fromNodeId: 'file:lib/main.dart', + referenceName: 'package:flutter/material.dart', + referenceKind: 'imports' as const, + line: 1, + column: 0, + filePath: 'lib/main.dart', + language: 'dart' as const, + }; + const result = flutterResolver.resolve(ref, flutterBaseContext as any); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(1.0); + expect(result?.targetNodeId).toBe(ref.fromNodeId); + }); + + it('short-circuits built-in widget names (Scaffold) to framework-provided', () => { + const ref = { + fromNodeId: 'class:lib/home.dart:HomeScreen:5', + referenceName: 'Scaffold', + referenceKind: 'calls' as const, + line: 10, + column: 4, + filePath: 'lib/home.dart', + language: 'dart' as const, + }; + const result = flutterResolver.resolve(ref, flutterBaseContext as any); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(1.0); + }); + + it('resolves a PascalCase user widget to its class node, preferring /lib/widgets/', () => { + const myButton: Node = { + id: 'class:lib/widgets/my_button.dart:MyButton:3', + kind: 'class', + name: 'MyButton', + qualifiedName: 'lib/widgets/my_button.dart::MyButton', + filePath: 'lib/widgets/my_button.dart', + language: 'dart', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...flutterBaseContext, + getNodesByName: (n: string) => (n === 'MyButton' ? [myButton] : []), + }; + const ref = { + fromNodeId: 'class:lib/screens/home.dart:HomeScreen:5', + referenceName: 'MyButton', + referenceKind: 'calls' as const, + line: 8, + column: 4, + filePath: 'lib/screens/home.dart', + language: 'dart' as const, + }; + const result = flutterResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(myButton.id); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(0.85); + }); + + it('pairs a State reference to its widget class via candidates', () => { + const widget: Node = { + id: 'widget:lib/counter.dart:Counter:3', + kind: 'component', + name: 'Counter', + qualifiedName: 'lib/counter.dart::Counter', + filePath: 'lib/counter.dart', + language: 'dart', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...flutterBaseContext, + getNodesByName: (n: string) => (n === 'Counter' ? [widget] : []), + }; + const ref = { + fromNodeId: 'state:lib/counter.dart:_CounterState:10', + referenceName: 'State', + referenceKind: 'extends' as const, + line: 10, + column: 0, + filePath: 'lib/counter.dart', + language: 'dart' as const, + candidates: ['Counter'], + }; + const result = flutterResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(widget.id); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(0.9); + }); +}); + +describe('flutterResolver.extract', () => { + it('emits a component node for a StatelessWidget subclass', () => { + const src = ` +import 'package:flutter/material.dart'; + +class HomeScreen extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold(body: Text('hi')); +} +`; + const { nodes } = flutterResolver.extract!('lib/screens/home.dart', src); + const component = nodes.find((n) => n.name === 'HomeScreen'); + expect(component?.kind).toBe('component'); + expect(component?.language).toBe('dart'); + }); + + it('emits a component node for a StatefulWidget and pairs its State class', () => { + const src = ` +class Counter extends StatefulWidget { + @override + _CounterState createState() => _CounterState(); +} + +class _CounterState extends State { + @override + Widget build(BuildContext context) => Container(); +} +`; + const { nodes, references } = flutterResolver.extract!('lib/counter.dart', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['Counter', '_CounterState']); + expect(nodes.every((n) => n.kind === 'component')).toBe(true); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('State'); + expect(references[0].referenceKind).toBe('extends'); + expect(references[0].candidates).toEqual(['Counter']); + }); + + it('emits a class node for the runApp root widget', () => { + const src = ` +import 'package:flutter/material.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp(home: Container()); +} +`; + const { nodes } = flutterResolver.extract!('lib/main.dart', src); + expect(nodes.find((n) => n.name === 'MyApp' && n.kind === 'class')).toBeDefined(); + expect(nodes.find((n) => n.name === 'MyApp' && n.kind === 'component')).toBeDefined(); + }); + + it('skips runApp() for built-in widgets (no double-counting Material/Cupertino)', () => { + // Edge case: runApp(MaterialApp(...)) — don't emit a node for MaterialApp. + const src = `void main() { runApp(MaterialApp(home: Container())); }\n`; + const { nodes } = flutterResolver.extract!('lib/main.dart', src); + expect(nodes).toHaveLength(0); + }); + + it('ignores commented-out widget classes', () => { + const src = ` +// class Fake extends StatelessWidget {} +/* class AlsoFake extends StatefulWidget {} */ +class Real extends StatelessWidget {} +`; + const { nodes } = flutterResolver.extract!('lib/real.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['Real']); + }); +}); + +describe('flutterRouterResolver.extract', () => { + it('extracts named routes from a MaterialApp routes: map', () => { + const src = ` +MaterialApp( + home: HomeScreen(), + routes: { + '/login': (ctx) => LoginScreen(), + '/profile': (ctx) => ProfileScreen(), + }, +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/app.dart', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['/login', '/profile']); + expect(nodes.every((n) => n.kind === 'route')).toBe(true); + expect(references.map((r) => r.referenceName).sort()).toEqual(['LoginScreen', 'ProfileScreen']); + }); + + it('extracts a flat GoRoute path → builder mapping', () => { + const src = ` +final router = GoRouter( + routes: [ + GoRoute(path: '/users', builder: (c, s) => UsersScreen()), + ], +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/router.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/users']); + expect(references.map((r) => r.referenceName)).toEqual(['UsersScreen']); + }); + + it('joins nested GoRoute paths via parent prefix', () => { + const src = ` +final router = GoRouter( + routes: [ + GoRoute( + path: '/users', + builder: (c, s) => UsersScreen(), + routes: [ + GoRoute(path: ':id', builder: (c, s) => UserDetailScreen()), + ], + ), + ], +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/router.dart', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['/users', '/users/:id']); + expect(references.map((r) => r.referenceName).sort()).toEqual([ + 'UserDetailScreen', + 'UsersScreen', + ]); + }); + + it('extracts a GoRoute with a block-body pageBuilder (real-world idiom)', () => { + // Most real-world GoRouter code uses a block body for any non-trivial + // wrapping (page transitions, auth gates, key plumbing). The capture + // takes the first widget after `return`; the next hop (wrapper -> child + // screen) is then followed by the standard Dart constructor-call edges. + const src = ` +final router = GoRouter( + routes: [ + GoRoute( + path: '/books/popular', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + child: BooksScreen(), + ); + }, + ), + ], +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/router.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/books/popular']); + expect(references.map((r) => r.referenceName)).toEqual(['FadeTransitionPage']); + }); + + it('accepts a Dart constant (Routes.login) as the GoRoute path expression', () => { + // Real-world Flutter apps centralize paths in `class Routes { static const + // login = '/login'; … }` and reference them as `path: Routes.login`. The + // extractor surfaces the identifier as the route name so the route → handler + // edge still materializes; agents can read Routes.dart for the literal. + const src = ` +GoRoute( + path: Routes.login, + builder: (context, state) { + return LoginScreen(); + }, +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/routing/router.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/Routes.login']); + expect(references.map((r) => r.referenceName)).toEqual(['LoginScreen']); + }); + + it('captures the outer generic-typed page wrapper (FadeTransitionPage(…)) not a nested child', () => { + // Real bookstore sample: pageBuilder returns FadeTransitionPage(child: + // Builder(builder: (c) { return BookList(...); })). Without the optional + // <…> allowance, the regex stopped at `FadeTransitionPage` then backtracked + // and captured the inner `BookList`. The captured wrapper is fine — the + // wrapper -> child edge is followed by the standard call graph. + const src = ` +GoRoute( + path: '/books/popular', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + child: Builder( + builder: (context) { + return BookList(books: books); + }, + ), + ); + }, +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/router.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/books/popular']); + expect(references.map((r) => r.referenceName)).toEqual(['FadeTransitionPage']); + }); + + it('does not attribute a nested child GoRoute’s path to its parent ShellRoute', () => { + // The bookstore sample shape: ShellRoute wraps a chain of GoRoutes. The + // parent's `path:` / `pageBuilder:` must come from its OWN params, not + // from the descendants reachable via routes: [...]. + const src = ` +GoRouter( + routes: [ + ShellRoute( + builder: (context, state, child) { + return ScaffoldWrap(child: child); + }, + routes: [ + GoRoute( + path: '/books/popular', + builder: (context, state) { + return BooksScreen(); + }, + ), + ], + ), + ], +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/router.dart', src); + // Only the inner GoRoute has a `path:`, so only one route node should + // exist. The parent ShellRoute must NOT inherit the child's path. + expect(nodes.map((n) => n.name)).toEqual(['/books/popular']); + expect(references.map((r) => r.referenceName)).toEqual(['BooksScreen']); + }); + + it('extracts a MaterialApp route entry with a block-body builder', () => { + const src = ` +MaterialApp( + routes: { + '/profile': (ctx) { + return ProfileScreen(); + }, + }, +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/app.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/profile']); + expect(references.map((r) => r.referenceName)).toEqual(['ProfileScreen']); + }); + + it('resolves a route handler reference to a screen class', () => { + const profileScreen: Node = { + id: 'class:lib/screens/profile.dart:ProfileScreen:3', + kind: 'class', + name: 'ProfileScreen', + qualifiedName: 'lib/screens/profile.dart::ProfileScreen', + filePath: 'lib/screens/profile.dart', + language: 'dart', + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...flutterBaseContext, + getNodesByName: (n: string) => (n === 'ProfileScreen' ? [profileScreen] : []), + }; + const ref = { + fromNodeId: 'route:lib/app.dart:5:/profile', + referenceName: 'ProfileScreen', + referenceKind: 'references' as const, + line: 5, + column: 0, + filePath: 'lib/app.dart', + language: 'dart' as const, + }; + const result = flutterRouterResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(profileScreen.id); + expect(result?.confidence).toBe(0.85); + }); + + it('flutter: skips // and /* */ commented routes', () => { + const src = ` +// MaterialApp(routes: { '/fake': (c) => FakeScreen() }); +/* GoRoute(path: '/also-fake', builder: (c, s) => OtherFake()) */ +MaterialApp( + routes: { + '/real': (ctx) => RealScreen(), + }, +); +`; + const { nodes, references } = flutterRouterResolver.extract!('lib/app.dart', src); + expect(nodes.map((n) => n.name)).toEqual(['/real']); + expect(references.map((r) => r.referenceName)).toEqual(['RealScreen']); + }); +}); + +describe('flutterStateResolver.detect', () => { + it('detects flutter_bloc in pubspec.yaml', () => { + const context = { + ...flutterBaseContext, + readFile: (p: string) => + p === 'pubspec.yaml' ? `dependencies:\n flutter_bloc: ^8.0.0\n` : null, + }; + expect(flutterStateResolver.detect(context as any)).toBe(true); + }); + + it('detects riverpod', () => { + const context = { + ...flutterBaseContext, + readFile: (p: string) => + p === 'pubspec.yaml' ? `dependencies:\n flutter_riverpod: ^2.0.0\n` : null, + }; + expect(flutterStateResolver.detect(context as any)).toBe(true); + }); + + it('returns false when no state-management package is present', () => { + const context = { + ...flutterBaseContext, + readFile: (p: string) => + p === 'pubspec.yaml' ? `dependencies:\n flutter:\n sdk: flutter\n` : null, + }; + expect(flutterStateResolver.detect(context as any)).toBe(false); + }); +}); + +describe('flutterStateResolver.resolve', () => { + it('marks context.read as framework-provided when bloc is present', () => { + const context = { + ...flutterBaseContext, + readFile: (p: string) => + p === 'pubspec.yaml' ? `dependencies:\n flutter_bloc: ^8.0.0\n` : null, + }; + const ref = { + fromNodeId: 'method:lib/home.dart:build:10', + referenceName: 'context.read', + referenceKind: 'calls' as const, + line: 12, + column: 4, + filePath: 'lib/home.dart', + language: 'dart' as const, + }; + const result = flutterStateResolver.resolve(ref, context as any); + expect(result?.resolvedBy).toBe('framework'); + expect(result?.confidence).toBe(1.0); + }); + + it('resolves a *Bloc class name to /lib/bloc/, preferring conventional dir', () => { + const userBloc: Node = { + id: 'class:lib/bloc/user_bloc.dart:UserBloc:5', + kind: 'class', + name: 'UserBloc', + qualifiedName: 'lib/bloc/user_bloc.dart::UserBloc', + filePath: 'lib/bloc/user_bloc.dart', + language: 'dart', + startLine: 5, + endLine: 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...flutterBaseContext, + getNodesByName: (n: string) => (n === 'UserBloc' ? [userBloc] : []), + readFile: (p: string) => + p === 'pubspec.yaml' ? `dependencies:\n flutter_bloc: ^8.0.0\n` : null, + }; + const ref = { + fromNodeId: 'class:lib/home.dart:HomeScreen:5', + referenceName: 'UserBloc', + referenceKind: 'references' as const, + line: 12, + column: 4, + filePath: 'lib/home.dart', + language: 'dart' as const, + }; + const result = flutterStateResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(userBloc.id); + expect(result?.confidence).toBe(0.8); + }); + + it('does not match bloc patterns when bloc is not in pubspec', () => { + const context = { + ...flutterBaseContext, + readFile: () => null, + }; + const ref = { + fromNodeId: 'x', + referenceName: 'context.read', + referenceKind: 'calls' as const, + line: 1, + column: 1, + filePath: 'lib/x.dart', + language: 'dart' as const, + }; + expect(flutterStateResolver.resolve(ref, context as any)).toBeNull(); + }); +}); + +import { getFrameworkResolver } from '../src/resolution/frameworks'; + +describe('flutter resolvers registered in framework registry', () => { + it('exposes flutter, flutter-router, and flutter-state via getFrameworkResolver', () => { + expect(getFrameworkResolver('flutter')?.languages).toEqual(['dart']); + expect(getFrameworkResolver('flutter-router')?.languages).toEqual(['dart']); + expect(getFrameworkResolver('flutter-state')?.languages).toEqual(['dart']); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index cb7fdc855..e66e813d7 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -193,7 +193,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | PHP | Laravel | request → route → controller → Eloquent | R | ✅ **precise `Route::get([Ctrl::class,'m'])` / `'Ctrl@m'` → Ctrl@method** (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every `index`→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) | | PHP | Drupal | request → *.routing.yml → _controller/_form | R | ✅ **`claimsReference` for FQCN handlers** (`\Drupal\…\Class::method` passed the pre-filter only because the `::method` name was known; bare `_form` FQCNs `\…\FormClass` and single-colon `Class:method` controller-services were dropped before resolve()) + **single-colon controller match** + **detect via composer `type:drupal-*` / `name:drupal/*` + `*.info.yml` fallback** (a contrib module with empty `require` was undetected → 0 routes). admin_toolbar S **0→14 (14/14)** / webform M 208 (**144**) / core L 836 (536→**731, 87%**). Remainder is the **entity-annotation handler frontier** (`_entity_form: type.op` resolves via the entity's PHP `#[ContentEntityType]` handlers, not a direct class). 🔬 **OOP `#[Hook]` attributes** — Drupal 11 moved ~all procedural hooks to attribute methods (core: 418 `#[Hook]` files vs 3 procedural), so the resolver's docblock/`module_hook` detection is obsolete for modern core (0 hook edges) | | C/C++ | C++ vtables / inheritance | virtual call → override; general direct dispatch | S + X | ✅ **general dispatch strong** (redis C **29k** cross-file calls / leveldb C++ **1.4k**) + **C++ inheritance extraction fix** (`base_class_clause` was unhandled, so C++ extends edges were missing — leveldb **219→298**) + **cpp-override synthesizer** (base virtual method → subclass override, gated to C++, capped — leveldb 12 precise: `Iterator::Next→MergingIterator`). 🔬 C callback structs (`s->fn()` → 422-way fan-out, too noisy to synthesize) + C++ pure-virtual base methods (`virtual void f()=0;` declarations aren't extracted as nodes, so those overrides can't bridge) | -| Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes | +| Dart | Flutter | setState → build; build → child widgets; request → route → handler; state-mgmt dispatch → service | R + S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). + **framework resolvers** (`frameworks/flutter.ts`, #420): **`flutter`** (widget extraction — StatelessWidget/StatefulWidget/Consumer/Hook subclasses → component nodes, `_FooState extends State` paired to its widget, `runApp(MyApp())` root, ~140 Material/Cupertino builtin short-circuit, `package:flutter/*` framework-provided); **`flutter-router`** (the matrix's previous `🔬 Navigator/MaterialApp routes` gap — MaterialApp `routes:` maps + `GoRoute`/`ShellRoute` trees with parent-prefix joining, block-body builders (`{ return Screen(); }` dominant in real apps), generic-typed page wrappers (`pageBuilder: …{ return FadeTransitionPage(child: Screen()); }`), top-level-only param scope so descendants don't contaminate parent paths, constant-reference paths (`path: Routes.login`)); **`flutter-state`** (the matrix's previous `🔬 MVVM Command/ChangeNotifier` gap — Provider/Riverpod/Bloc/GetX detected per-package from `pubspec.yaml`; dispatch shapes `context.read`/`ref.watch`/`BlocProvider.of`/`Get.find` short-circuit as framework-provided; `*Provider`/`*Bloc`/`*Cubit`/`*Controller` resolve preferring conventional dirs). Validated end-to-end via `scripts/agent-eval/validate-flutter.sh`: navigation_and_routing S (306 nodes, **10/10 routes**, 15 widgets, 8 route→handler edges), compass_app/app M (1873 nodes, **7/7 routes** through Routes.X const-ref, 50 widgets, MVVM acknowledged as unsynthesized), express control unchanged (990 nodes, 266 routes). + **Agent A/B (headless, n=2 per arm, Opus via Claude Code OAuth)**: small "navigate to /sign-in → SignInScreen" — with-arm **Read 0–1 / 3–4 tool calls / 23–26s**, without-arm **Read 2 / 5–8 tool calls / 34–44s**; medium "Routes.home → BookingScreen + ViewModel" — with-arm **Read 0–1 / 3–4 tool calls / 24–25s**, without-arm **Read 4 / 8 tool calls / 35–47s**. Aggregate: **Read 3.0 → 0.5 (−83%), duration 40s → 24.5s (−39%), tool calls −46%**, total spend $1.80 across 4 A/Bs. With-arms consistently chose `codegraph_context` + `codegraph_explore` / `codegraph_node`; without-arms ls/grep'd then Read the router + screen + viewmodel files. 🔬 ChangeNotifier→listener cross-symbol synthesis (compass_app reactive updates still dynamic-dispatch); `Navigator.push(MaterialPageRoute(builder:))` inline anonymous route shape (only the named `MaterialApp(routes:)` map + GoRoute are extracted) | | Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer | | Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler | | Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) | diff --git a/scripts/agent-eval/validate-flutter.sh b/scripts/agent-eval/validate-flutter.sh new file mode 100755 index 000000000..43f5735c3 --- /dev/null +++ b/scripts/agent-eval/validate-flutter.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Flutter framework quality check — per the dynamic-dispatch-coverage-playbook. +# +# Runs the DETERMINISTIC half of the validation methodology (no agent, no API +# spend) against small + medium Flutter sample apps. Verifies that the three +# framework resolvers (flutter, flutter-router, flutter-state) emit the +# expected nodes/edges, AND that the existing upstream `setState→build` +# synthesizer (callback-synthesizer.ts) still fires where applicable, AND +# that a non-Dart control repo doesn't regress. +# +# Usage: +# scripts/agent-eval/validate-flutter.sh # full sweep (default) +# scripts/agent-eval/validate-flutter.sh small # navigation_and_routing only +# scripts/agent-eval/validate-flutter.sh medium # compass_app/app only +# scripts/agent-eval/validate-flutter.sh control # express regression only +# +# Env: +# CG_BIN codegraph binary (default: ./dist/bin/codegraph.js) +# EVAL_BASE sample repos dir (default: /tmp/flutter-eval) +# CONTROL_DIR express checkout dir (default: /tmp/control) +# +# Exit codes: 0 = all checks pass · 1 = a check failed (specifics in output). +# +# This is the gate per CLAUDE.md "Validation methodology (REQUIRED for every +# new language/framework)". Agent A/B (run-all.sh) is the second half — +# requires Anthropic API spend; run separately when budget allows. + +set -uo pipefail + +HARNESS="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HARNESS/../.." && pwd)" +CG_BIN="${CG_BIN:-$REPO_ROOT/dist/bin/codegraph.js}" +EVAL_BASE="${EVAL_BASE:-/tmp/flutter-eval}" +CONTROL_DIR="${CONTROL_DIR:-/tmp/control}" +MODE="${1:-all}" + +case "$MODE" in all|small|medium|control) ;; *) echo "mode must be all|small|medium|control (got '$MODE')"; exit 1;; esac + +[ -f "$CG_BIN" ] || { echo "✗ no codegraph binary at $CG_BIN (run 'npm run build' first)"; exit 1; } +command -v sqlite3 >/dev/null || { echo "✗ sqlite3 not on PATH"; exit 1; } + +PASS=0; FAIL=0 +ok() { echo " ✓ $*"; PASS=$((PASS+1)); } +fail() { echo " ✗ $*"; FAIL=$((FAIL+1)); } + +# Ensure the flutter/samples repo is cloned. Shallow only. +ensure_flutter_samples() { + if [ ! -d "$EVAL_BASE/samples/.git" ]; then + echo "→ cloning flutter/samples (shallow) into $EVAL_BASE/samples" + mkdir -p "$EVAL_BASE" + git -C "$EVAL_BASE" clone --depth 1 https://github.com/flutter/samples.git >/dev/null 2>&1 \ + || { echo "✗ flutter/samples clone failed"; exit 1; } + fi +} + +# Ensure express is cloned for the control regression check. +ensure_control() { + if [ ! -d "$CONTROL_DIR/express/.git" ]; then + echo "→ cloning expressjs/express (shallow) into $CONTROL_DIR/express" + mkdir -p "$CONTROL_DIR" + git -C "$CONTROL_DIR" clone --depth 1 https://github.com/expressjs/express >/dev/null 2>&1 \ + || { echo "✗ express clone failed"; exit 1; } + fi +} + +reindex() { + local repo="$1" + ( cd "$repo" && rm -rf .codegraph && node "$CG_BIN" init -i ) >/dev/null 2>&1 \ + || { echo "✗ indexing failed for $repo"; return 1; } +} + +count_nodes() { + local db="$1" kind="$2" + sqlite3 "$db" "select count(*) from nodes where kind='$kind';" +} +count_edges() { + local db="$1" prov="$2" + sqlite3 "$db" "select count(*) from edges where provenance='$prov';" +} + +# ---------------------------------------------------------------------------- +# SMALL — navigation_and_routing (go_router, 17 .dart files) +# Exercises: flutter-router (GoRoute/ShellRoute extraction), block-body +# builders, generic-typed page wrappers, nested path joining, widget components. +# ---------------------------------------------------------------------------- +check_small() { + echo + echo "==== SMALL: navigation_and_routing (go_router) ====" + ensure_flutter_samples + local repo="$EVAL_BASE/samples/navigation_and_routing" + [ -d "$repo" ] || { fail "$repo missing (flutter/samples may have moved this app)"; return; } + reindex "$repo" || { fail "indexing failed"; return; } + local db="$repo/.codegraph/codegraph.db" + + local nodes routes components heuristic + nodes=$(sqlite3 "$db" "select count(*) from nodes;") + routes=$(count_nodes "$db" route) + components=$(count_nodes "$db" component) + heuristic=$(count_edges "$db" heuristic) + + [ "$nodes" -gt 200 ] && ok "indexed $nodes nodes (expected >200)" || fail "indexed only $nodes nodes" + [ "$routes" -ge 8 ] && ok "$routes route nodes (expected ≥8 for the 10 GoRoute calls)" || fail "only $routes route nodes — flutter-router regression" + [ "$components" -ge 12 ] && ok "$components component nodes (StatelessWidget/StatefulWidget/State pairs)" || fail "only $components component nodes — flutter widget extraction regression" + ok "synthesized edges = $heuristic (app has no setState; flutter-build synth correctly idle)" + + # Confirm at least one route → handler edge resolves to a screen widget. + local handler_edges + handler_edges=$(sqlite3 "$db" "select count(*) from edges e join nodes s on e.source=s.id join nodes t on e.target=t.id where s.kind='route' and t.kind in ('class','component');") + [ "$handler_edges" -ge 6 ] && ok "$handler_edges route→handler edges resolved" || fail "only $handler_edges route→handler edges" +} + +# ---------------------------------------------------------------------------- +# MEDIUM — compass_app/app (go_router + provider + MVVM, 129 .dart files) +# Exercises: flutter-router with Routes.X constant-ref paths, flutter-state +# (provider detection), widget composition at scale. +# ---------------------------------------------------------------------------- +check_medium() { + echo + echo "==== MEDIUM: compass_app/app (go_router + provider + MVVM) ====" + ensure_flutter_samples + local repo="$EVAL_BASE/samples/compass_app/app" + [ -d "$repo" ] || { fail "$repo missing"; return; } + reindex "$repo" || { fail "indexing failed"; return; } + local db="$repo/.codegraph/codegraph.db" + + local nodes routes components heuristic + nodes=$(sqlite3 "$db" "select count(*) from nodes;") + routes=$(count_nodes "$db" route) + components=$(count_nodes "$db" component) + heuristic=$(count_edges "$db" heuristic) + + [ "$nodes" -gt 1500 ] && ok "indexed $nodes nodes (expected >1500)" || fail "indexed only $nodes nodes" + [ "$routes" -ge 6 ] && ok "$routes route nodes (expected ≥6 — Routes.X const-ref + nested)" || fail "only $routes route nodes — const-ref path regression" + [ "$components" -ge 40 ] && ok "$components component nodes (expected ≥40 widget classes)" || fail "only $components component nodes" + ok "synthesized edges = $heuristic (app uses Command/ChangeNotifier, not setState; flutter-build synth correctly idle — see playbook §6 'MVVM Command/ChangeNotifier' known gap)" +} + +# ---------------------------------------------------------------------------- +# CONTROL — express (TypeScript, ~50 files) +# Regression gate: our Dart-only changes must not affect a non-Dart project. +# ---------------------------------------------------------------------------- +check_control() { + echo + echo "==== CONTROL: expressjs/express (regression gate) ====" + ensure_control + local repo="$CONTROL_DIR/express" + reindex "$repo" || { fail "indexing failed"; return; } + local db="$repo/.codegraph/codegraph.db" + local nodes routes + nodes=$(sqlite3 "$db" "select count(*) from nodes;") + routes=$(count_nodes "$db" route) + [ "$nodes" -gt 800 ] && ok "indexed $nodes nodes (baseline ~990)" || fail "indexed only $nodes nodes — possible regression" + [ "$routes" -gt 200 ] && ok "$routes route nodes (baseline ~266) — express extraction unaffected" || fail "only $routes route nodes — non-Dart regression" +} + +echo "Flutter framework quality check" +echo "(deterministic half of the playbook validation — no agent / no API spend)" +echo "cg-bin: $CG_BIN" + +case "$MODE" in + small) check_small ;; + medium) check_medium ;; + control) check_control ;; + all) check_small; check_medium; check_control ;; +esac + +echo +echo "================================================================" +echo "SUMMARY: $PASS passed, $FAIL failed" +echo "================================================================" +[ "$FAIL" -eq 0 ] diff --git a/src/resolution/frameworks/flutter.ts b/src/resolution/frameworks/flutter.ts new file mode 100644 index 000000000..ccfc9acc9 --- /dev/null +++ b/src/resolution/frameworks/flutter.ts @@ -0,0 +1,743 @@ +/** + * Flutter Framework Resolver + * + * Handles Flutter widgets (StatelessWidget/StatefulWidget + State pairing), + * navigation (MaterialApp routes + GoRouter), and popular state-management + * packages (Provider, Riverpod, Bloc/Cubit, GetX). + * + * Mirrors the layout of swift.ts: multiple resolvers in one file, each with + * its own detect() so a project that uses Bloc but not Riverpod only pays for + * the Bloc patterns. + */ + +import { Node } from '../../types'; +import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; + +// =========================================================================== +// flutterResolver — core widget framework +// =========================================================================== + +export const flutterResolver: FrameworkResolver = { + name: 'flutter', + languages: ['dart'], + + detect(context: ResolutionContext): boolean { + // Primary signal: pubspec.yaml with a `flutter:` SDK block under deps. + const pubspec = context.readFile('pubspec.yaml'); + if (pubspec && /^\s*flutter\s*:\s*\n\s*sdk\s*:\s*flutter\b/m.test(pubspec)) { + return true; + } + + // Fallback: any .dart file imports package:flutter/*. + const allFiles = context.getAllFiles(); + for (const file of allFiles) { + if (!file.endsWith('.dart')) continue; + const content = context.readFile(file); + if (content && /import\s+['"]package:flutter\//.test(content)) { + return true; + } + } + + return false; + }, + + claimsReference(name: string): boolean { + // State reaches here as the bare name `State` — let it through + // so we can pair it with its widget via the candidates field. + return name === 'State'; + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Pattern 1: framework-provided imports (package:flutter/*, dart:ui, etc.) + if (ref.referenceKind === 'imports') { + if ( + ref.referenceName.startsWith('package:flutter/') || + ref.referenceName.startsWith('package:flutter_') || + ref.referenceName === 'dart:ui' || + ref.referenceName === 'dart:ui_web' + ) { + return frameworkSelf(ref, 1.0); + } + } + + // Pattern 2: State companion — pair to the widget class named in + // candidates (extract() seeds the candidates list for this case). + // Checked BEFORE the built-in short-circuit so a `State` ref with + // candidates resolves to the widget instead of self-targeting. + if (ref.referenceName === 'State' && ref.candidates && ref.candidates.length > 0) { + const widgetName = ref.candidates[0]!; + const widget = context + .getNodesByName(widgetName) + .find((n) => n.kind === 'class' || n.kind === 'component'); + if (widget) { + return { + original: ref, + targetNodeId: widget.id, + confidence: 0.9, + resolvedBy: 'framework', + }; + } + } + + // Pattern 3: built-in Material/Cupertino/Widgets — short-circuit so the + // name resolver doesn't waste cycles looking for user-defined nodes. + if (FLUTTER_BUILTIN_WIDGETS.has(ref.referenceName)) { + return frameworkSelf(ref, 1.0); + } + + // Pattern 4: user widget reference — PascalCase, look up as class/component. + if (isPascalCase(ref.referenceName)) { + const result = resolveByNameAndKind( + ref.referenceName, + WIDGET_CLASS_KINDS, + [...WIDGET_DIRS, ...SCREEN_DIRS], + ref.filePath, + context + ); + if (result) { + return { + original: ref, + targetNodeId: result, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + } + + return null; + }, + + extract(filePath, content) { + if (!filePath.endsWith('.dart')) return { nodes: [], references: [] }; + + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + const safe = stripCommentsForRegex(content, 'dart'); + + // class FooBar extends StatelessWidget | StatefulWidget | ConsumerWidget | HookWidget | ... + const widgetPattern = /\bclass\s+(\w+)(?:\s*<[^>]+>)?\s+extends\s+((?:Stateless|Stateful|Consumer(?:Stateful)?|Hook(?:Consumer)?|InheritedWidget|StatefulHook)Widget|StatelessWidget|StatefulWidget)\b/g; + let match: RegExpExecArray | null; + while ((match = widgetPattern.exec(safe)) !== null) { + const [, widgetName] = match; + const line = safe.slice(0, match.index).split('\n').length; + nodes.push({ + id: `widget:${filePath}:${widgetName}:${line}`, + kind: 'component', + name: widgetName!, + qualifiedName: `${filePath}::${widgetName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'dart', + updatedAt: now, + }); + } + + // class _FooBarState extends State { ... } — pair with its widget. + const statePattern = /\bclass\s+(\w+)(?:\s*<[^>]+>)?\s+extends\s+(?:State|ConsumerState)\s*<\s*(\w+)\s*>/g; + while ((match = statePattern.exec(safe)) !== null) { + const [, stateName, widgetName] = match; + const line = safe.slice(0, match.index).split('\n').length; + const stateNode: Node = { + id: `state:${filePath}:${stateName}:${line}`, + kind: 'component', + name: stateName!, + qualifiedName: `${filePath}::${stateName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'dart', + updatedAt: now, + }; + nodes.push(stateNode); + // Reference from State class → its widget (the candidate carries the + // widget name so resolve() can pair them under the `State` short name). + references.push({ + fromNodeId: stateNode.id, + referenceName: 'State', + referenceKind: 'extends', + line, + column: 0, + filePath, + language: 'dart', + candidates: [widgetName!], + }); + } + + // void main() { runApp(MyApp()); } — emit an `app` node for the root widget. + const runAppPattern = /\brunApp\s*\(\s*(?:const\s+)?(\w+)\s*\(/g; + while ((match = runAppPattern.exec(safe)) !== null) { + const [, rootName] = match; + const line = safe.slice(0, match.index).split('\n').length; + // Don't double-count: only if not already a Material/Cupertino built-in + if (!FLUTTER_BUILTIN_WIDGETS.has(rootName!)) { + nodes.push({ + id: `app:${filePath}:${rootName}:${line}`, + kind: 'class', + name: rootName!, + qualifiedName: `${filePath}::${rootName}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + language: 'dart', + updatedAt: now, + }); + } + } + + return { nodes, references }; + }, +}; + +// =========================================================================== +// flutterRouterResolver — Navigator named routes + GoRouter +// =========================================================================== + +export const flutterRouterResolver: FrameworkResolver = { + name: 'flutter-router', + languages: ['dart'], + + detect(context: ResolutionContext): boolean { + const pubspec = context.readFile('pubspec.yaml'); + if (pubspec && /\bgo_router\s*:/.test(pubspec)) { + return true; + } + // Also detect inline named-routes maps in MaterialApp. + const allFiles = context.getAllFiles(); + for (const file of allFiles) { + if (!file.endsWith('.dart')) continue; + const content = context.readFile(file); + if (!content) continue; + if (/MaterialApp\s*\([\s\S]{0,500}\broutes\s*:\s*\{/.test(content)) { + return true; + } + if (/\bGoRoute\s*\(/.test(content)) { + return true; + } + } + return false; + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Route→handler refs synthesized in extract() carry a PascalCase widget + // class name. Resolve them preferring screen/page dirs. + if (isPascalCase(ref.referenceName) && ref.referenceKind === 'references') { + const result = resolveByNameAndKind( + ref.referenceName, + WIDGET_CLASS_KINDS, + [...SCREEN_DIRS, ...WIDGET_DIRS], + ref.filePath, + context + ); + if (result) { + return { + original: ref, + targetNodeId: result, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + } + return null; + }, + + extract(filePath, content) { + if (!filePath.endsWith('.dart')) return { nodes: [], references: [] }; + + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const safe = stripCommentsForRegex(content, 'dart'); + + // MaterialApp(... routes: { '/foo': (ctx) => FooScreen(), '/bar': (ctx) { return BarScreen(); } }) + const materialRoutesBlock = safe.match(/MaterialApp\s*\([\s\S]*?\broutes\s*:\s*\{([\s\S]*?)\n\s*\}/); + if (materialRoutesBlock && materialRoutesBlock[1]) { + const body = materialRoutesBlock[1]; + const bodyOffset = materialRoutesBlock.index! + materialRoutesBlock[0].indexOf(body); + // Accept arrow form `=> Screen(` and block form `{ ... return Screen(`. + // Allow optional generics on the widget name (`Page(`). + const entryRegex = /['"]([^'"]+)['"]\s*:\s*\([^)]*\)\s*(?:=>\s*(?:const\s+)?(\w+)(?:\s*<[^>]+>)?\s*\(|\{[\s\S]*?\breturn\s+(?:const\s+)?(\w+)(?:\s*<[^>]+>)?\s*\()/g; + let em: RegExpExecArray | null; + while ((em = entryRegex.exec(body)) !== null) { + const path = em[1]!; + const handler = em[2] ?? em[3]; + if (!handler) continue; + const absIdx = bodyOffset + em.index; + const line = safe.slice(0, absIdx).split('\n').length; + emitRoute(nodes, references, filePath, path, handler, line, em[0].length); + } + } + + // GoRouter / ShellRoute / GoRoute(path: '/x', builder: (c, s) => XScreen()) + // Handles nested routes by tracking parent path prefix via a depth-aware + // walk of `GoRoute(...)` calls. We don't parse the full Dart AST — we + // scan for GoRoute openings, capture their `path:` and `builder:` / + // `pageBuilder:`, and track parent prefix on a stack as we descend into + // a `routes:` array. + extractGoRouter(safe, filePath, nodes, references); + + return { nodes, references }; + }, +}; + +function extractGoRouter( + safe: string, + filePath: string, + nodes: Node[], + references: UnresolvedRef[] +): void { + // Scan for `GoRoute(` and `ShellRoute(` openings. Use a stack of + // {parentPrefix, closeAt} entries. closeAt is the matching `)` of the + // owning route — when we cross it, pop. Within each route, look for + // `path: '...'`, `builder: (...) => Widget(`, and a `routes: [` opening + // that pushes a child frame. + type Frame = { parentPrefix: string; closeAt: number; ownPath: string | null; handlerEmitted: boolean }; + const stack: Frame[] = []; + let i = 0; + const n = safe.length; + + const findMatchingParen = (start: number): number => { + let depth = 0; + for (let j = start; j < n; j++) { + const c = safe[j]; + if (c === '(') depth++; + else if (c === ')') { + depth--; + if (depth === 0) return j; + } + } + return -1; + }; + + while (i < n) { + // Pop any frames whose route has closed. + while (stack.length > 0 && i >= stack[stack.length - 1]!.closeAt) { + stack.pop(); + } + + const goRoute = safe.indexOf('GoRoute(', i); + const shellRoute = safe.indexOf('ShellRoute(', i); + let next = -1; + let openOffset = 0; + if (goRoute !== -1 && (shellRoute === -1 || goRoute < shellRoute)) { + next = goRoute; + openOffset = 'GoRoute'.length; + } else if (shellRoute !== -1) { + next = shellRoute; + openOffset = 'ShellRoute'.length; + } + if (next === -1) break; + + // Pop frames closed before this opening. + while (stack.length > 0 && next >= stack[stack.length - 1]!.closeAt) { + stack.pop(); + } + + const openParen = next + openOffset; + const closeAt = findMatchingParen(openParen); + if (closeAt === -1) break; + + const parentPrefix = stack.length > 0 ? stack[stack.length - 1]!.parentPrefix : ''; + const fullBody = safe.slice(openParen + 1, closeAt); + + // Limit the search to TOP-LEVEL params of this route — everything before + // the `routes: [` array opening. Otherwise `path:` / `pageBuilder:` regex + // matches against a descendant GoRoute's values and produces phantom + // route nodes attributed to the wrong path (or wrapper widget). + const childrenIdx = fullBody.search(/\broutes\s*:\s*\[/); + const body = childrenIdx >= 0 ? fullBody.slice(0, childrenIdx) : fullBody; + + // path: '...' OR path: SomeConst.member (real-world Flutter apps + // commonly centralize route paths in a `class Routes { static const … }` + // file and reference them as `Routes.login`. Without this, only routes + // whose path is an inline string literal get picked up.) + let ownPath: string | null = null; + const pathMatch = body.match(/\bpath\s*:\s*(?:['"]([^'"]*)['"]|([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*))/); + if (pathMatch) { + ownPath = pathMatch[1] ?? pathMatch[2] ?? ''; + } + + // builder or pageBuilder accepts both expression form (=> Widget(…)) and + // block form ({ … return Widget(…); }). Real-world GoRouter code uses the + // block form for any non-trivial route (auth gating, page-transition + // wrappers), so missing it leaves most routes unparsed. + // Allow generic-typed widget names (`FadeTransitionPage(`) — without + // this, the regex stops at the bare name and the trailing `<` makes the + // pattern fail, so backtracking falls through to a DEEPER `return Widget(` + // (e.g. the nested `Builder(builder: …) { return BookList(...)`), capturing + // the wrong handler. + let handler: string | null = null; + const arrowMatch = body.match(/\b(?:builder|pageBuilder)\s*:\s*\([^)]*\)\s*=>\s*(?:const\s+)?(\w+)(?:\s*<[^>]+>)?\s*\(/); + if (arrowMatch) { + handler = arrowMatch[1] ?? null; + } else { + const blockMatch = body.match(/\b(?:builder|pageBuilder)\s*:\s*\([^)]*\)\s*\{[\s\S]*?\breturn\s+(?:const\s+)?(\w+)(?:\s*<[^>]+>)?\s*\(/); + if (blockMatch) handler = blockMatch[1] ?? null; + } + + const fullPath = joinGoRoutePath(parentPrefix, ownPath); + + if (ownPath !== null && handler) { + const absIdx = next; + const line = safe.slice(0, absIdx).split('\n').length; + emitRoute(nodes, references, filePath, fullPath, handler, line, openOffset); + } + + // Push frame so any nested `routes:` will inherit fullPath. + stack.push({ parentPrefix: fullPath, closeAt, ownPath, handlerEmitted: !!handler }); + + // Skip past the route's `routes: [...]` so we don't re-scan its inside as + // a sibling. Walking is handled by stack pop above; just advance past the + // GoRoute(/ShellRoute( opening so we don't infinite-loop on this match. + i = openParen + 1; + } +} + +function joinGoRoutePath(parent: string, own: string | null): string { + if (!own) return parent || '/'; + if (own.startsWith('/')) return own; // absolute child + if (!parent || parent === '/') return '/' + own.replace(/^\/+/, ''); + return parent.replace(/\/+$/, '') + '/' + own.replace(/^\/+/, ''); +} + +function emitRoute( + nodes: Node[], + references: UnresolvedRef[], + filePath: string, + path: string, + handler: string, + line: number, + endColumn: number +): void { + const normalized = path.startsWith('/') ? path : '/' + path; + const routeNode: Node = { + id: `route:${filePath}:${line}:${normalized}`, + kind: 'route', + name: normalized, + qualifiedName: `${filePath}::route:${normalized}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn, + language: 'dart', + updatedAt: Date.now(), + }; + nodes.push(routeNode); + references.push({ + fromNodeId: routeNode.id, + referenceName: handler, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'dart', + }); +} + +// =========================================================================== +// flutterStateResolver — Provider / Riverpod / Bloc / GetX +// =========================================================================== + +export const flutterStateResolver: FrameworkResolver = { + name: 'flutter-state', + languages: ['dart'], + + detect(context: ResolutionContext): boolean { + const flags = readStateMgmtFlags(context); + return flags.provider || flags.riverpod || flags.bloc || flags.getx; + }, + + claimsReference(name: string): boolean { + // Dispatch shapes the dart extractor surfaces as `obj.method` — these are + // framework dispatches with no user-declared symbol of that name. + return STATE_DISPATCH_NAMES.has(name); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const flags = readStateMgmtFlags(context); + + // Pattern 1: framework dispatch shapes → self-target (1.0). Short-circuits + // so fuzzy name matching doesn't link these calls to unrelated symbols. + if (STATE_DISPATCH_NAMES.has(ref.referenceName)) { + if ( + (flags.provider && PROVIDER_DISPATCH.has(ref.referenceName)) || + (flags.riverpod && RIVERPOD_DISPATCH.has(ref.referenceName)) || + (flags.bloc && BLOC_DISPATCH.has(ref.referenceName)) || + (flags.getx && GETX_DISPATCH.has(ref.referenceName)) + ) { + return frameworkSelf(ref, 1.0); + } + } + + // Pattern 2: Riverpod *Provider symbols (provider variables/functions + // declared at top level) — prefer provider/state dirs. + if (flags.riverpod && /Provider$/.test(ref.referenceName) && isPascalishVariable(ref.referenceName)) { + const result = resolveByNameAndKind( + ref.referenceName, + PROVIDER_KINDS, + PROVIDER_DIRS, + ref.filePath, + context + ); + if (result) { + return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; + } + } + + // Pattern 3: Bloc/Cubit classes — prefer bloc/cubit dirs. + if (flags.bloc && (/Bloc$/.test(ref.referenceName) || /Cubit$/.test(ref.referenceName))) { + const result = resolveByNameAndKind( + ref.referenceName, + WIDGET_CLASS_KINDS, + BLOC_DIRS, + ref.filePath, + context + ); + if (result) { + return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; + } + } + + // Pattern 4: GetX *Controller classes — prefer controller dirs. + if (flags.getx && /Controller$/.test(ref.referenceName) && isPascalCase(ref.referenceName)) { + const result = resolveByNameAndKind( + ref.referenceName, + WIDGET_CLASS_KINDS, + GETX_DIRS, + ref.filePath, + context + ); + if (result) { + return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; + } + } + + return null; + }, +}; + +function readStateMgmtFlags(context: ResolutionContext): { + provider: boolean; + riverpod: boolean; + bloc: boolean; + getx: boolean; +} { + const pubspec = context.readFile('pubspec.yaml') ?? ''; + return { + provider: /^\s*provider\s*:/m.test(pubspec), + riverpod: /^\s*(?:flutter_)?riverpod\s*:/m.test(pubspec) || /^\s*hooks_riverpod\s*:/m.test(pubspec), + bloc: /^\s*(?:flutter_)?bloc\s*:/m.test(pubspec), + // `get:` matches the GetX package; pubspec keys are unique so no collision. + getx: /^\s*get\s*:/m.test(pubspec) || /^\s*get_it\s*:/m.test(pubspec), + }; +} + +// =========================================================================== +// Shared helpers +// =========================================================================== + +function frameworkSelf(ref: UnresolvedRef, confidence: number): ResolvedRef { + return { + original: ref, + targetNodeId: ref.fromNodeId, + confidence, + resolvedBy: 'framework', + }; +} + +function isPascalCase(s: string): boolean { + return /^[A-Z][a-zA-Z0-9_]*$/.test(s); +} + +// Riverpod providers are camelCase variables (e.g. `userProvider`), not +// PascalCase classes — accept either so we cover both `final userProvider = +// Provider(...)` and a hypothetical `UserProvider` class. +function isPascalishVariable(s: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s); +} + +function resolveByNameAndKind( + name: string, + kinds: Set, + preferredDirPatterns: string[], + fromFilePath: string, + context: ResolutionContext +): string | null { + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; + const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); + if (kindFiltered.length === 0) return null; + + // Prefer same-file + const sameFile = kindFiltered.filter((n) => n.filePath === fromFilePath); + if (sameFile.length > 0) return sameFile[0]!.id; + + // Prefer same-directory + const fromDir = fromFilePath.substring(0, fromFilePath.lastIndexOf('/')); + if (fromDir) { + const sameDir = kindFiltered.filter((n) => n.filePath.startsWith(fromDir + '/')); + if (sameDir.length > 0) return sameDir[0]!.id; + } + + // Prefer framework-conventional directories + if (preferredDirPatterns.length > 0) { + const preferred = kindFiltered.filter((n) => + preferredDirPatterns.some((d) => n.filePath.includes(d)) + ); + if (preferred.length > 0) return preferred[0]!.id; + } + + return kindFiltered[0]!.id; +} + +// =========================================================================== +// Constants +// =========================================================================== + +const WIDGET_DIRS = ['/lib/widgets/', '/lib/components/', '/lib/ui/']; +const SCREEN_DIRS = ['/lib/screens/', '/lib/pages/', '/lib/views/', '/lib/routes/']; +const PROVIDER_DIRS = ['/lib/providers/', '/lib/state/', '/lib/notifiers/']; +const BLOC_DIRS = ['/lib/bloc/', '/lib/blocs/', '/lib/cubit/', '/lib/cubits/']; +const GETX_DIRS = ['/lib/controllers/', '/lib/getx/']; + +const WIDGET_CLASS_KINDS = new Set(['class', 'component']); +const PROVIDER_KINDS = new Set(['variable', 'constant', 'function', 'class']); + +const PROVIDER_DISPATCH = new Set([ + 'Provider.of', + 'context.read', + 'context.watch', + 'context.select', + 'Consumer', + 'Consumer2', + 'Consumer3', + 'MultiProvider', + 'ChangeNotifierProvider', + 'FutureProvider', + 'StreamProvider', + 'ListenableProvider', + 'ValueListenableProvider', + 'ProxyProvider', +]); + +const RIVERPOD_DISPATCH = new Set([ + 'ref.read', + 'ref.watch', + 'ref.listen', + 'ref.refresh', + 'ref.invalidate', + 'ProviderScope', + 'Consumer', + 'ConsumerWidget', + 'ConsumerStatefulWidget', + 'HookConsumerWidget', + 'useProvider', +]); + +const BLOC_DISPATCH = new Set([ + 'BlocProvider', + 'BlocProvider.of', + 'BlocBuilder', + 'BlocListener', + 'BlocConsumer', + 'BlocSelector', + 'MultiBlocProvider', + 'MultiBlocListener', + 'RepositoryProvider', + 'MultiRepositoryProvider', + 'context.read', + 'context.watch', +]); + +const GETX_DISPATCH = new Set([ + 'Get.find', + 'Get.put', + 'Get.lazyPut', + 'Get.create', + 'Get.delete', + 'Get.to', + 'Get.toNamed', + 'Get.back', + 'Get.off', + 'Get.offAll', + 'GetBuilder', + 'GetX', + 'Obx', + 'GetMaterialApp', +]); + +const STATE_DISPATCH_NAMES = new Set([ + ...PROVIDER_DISPATCH, + ...RIVERPOD_DISPATCH, + ...BLOC_DISPATCH, + ...GETX_DISPATCH, +]); + +/** + * Common Material/Cupertino/Widgets-library widget names. Not exhaustive — the + * goal is to short-circuit name resolution for the widgets a Flutter app + * touches on nearly every screen, so the name resolver doesn't waste cycles + * looking for user-defined symbols. Add more as benchmarks reveal misses. + */ +const FLUTTER_BUILTIN_WIDGETS = new Set([ + // App / scaffolding + 'MaterialApp', 'CupertinoApp', 'WidgetsApp', 'Scaffold', 'CupertinoPageScaffold', + 'AppBar', 'CupertinoNavigationBar', 'SliverAppBar', 'BottomNavigationBar', + 'CupertinoTabBar', 'CupertinoTabScaffold', 'Drawer', 'EndDrawer', 'NavigationBar', + 'NavigationRail', 'TabBar', 'TabBarView', 'TabController', 'DefaultTabController', + // Layout + 'Container', 'Row', 'Column', 'Stack', 'IndexedStack', 'Wrap', 'Flow', 'Table', + 'Padding', 'Center', 'Align', 'Positioned', 'Expanded', 'Flexible', 'Spacer', + 'SizedBox', 'ConstrainedBox', 'FractionallySizedBox', 'AspectRatio', 'FittedBox', + 'IntrinsicWidth', 'IntrinsicHeight', 'Baseline', 'LimitedBox', 'OverflowBox', + 'SafeArea', 'Material', 'DecoratedBox', 'Card', 'Chip', 'Dialog', 'AlertDialog', + 'SimpleDialog', 'CupertinoAlertDialog', 'BottomSheet', 'PopupMenuButton', 'Banner', + // Scrolling + 'ListView', 'GridView', 'SingleChildScrollView', 'CustomScrollView', 'NestedScrollView', + 'PageView', 'Scrollbar', 'ReorderableListView', 'RefreshIndicator', + // Display + 'Text', 'RichText', 'SelectableText', 'Icon', 'ImageIcon', 'Image', 'FadeInImage', + 'CircleAvatar', 'Divider', 'VerticalDivider', 'Placeholder', 'Tooltip', 'Badge', + 'Hero', 'Visibility', 'Offstage', 'Opacity', 'CircularProgressIndicator', + 'LinearProgressIndicator', 'CupertinoActivityIndicator', + // Input + 'TextField', 'TextFormField', 'Form', 'FormField', 'CupertinoTextField', + 'ElevatedButton', 'TextButton', 'OutlinedButton', 'IconButton', 'FloatingActionButton', + 'CupertinoButton', 'BackButton', 'CloseButton', 'Checkbox', 'CheckboxListTile', + 'Radio', 'RadioListTile', 'Switch', 'SwitchListTile', 'CupertinoSwitch', 'Slider', + 'CupertinoSlider', 'RangeSlider', 'DropdownButton', 'DropdownButtonFormField', + 'PopupMenuItem', 'MenuItemButton', 'MenuBar', 'SubmenuButton', + // List items + 'ListTile', 'ExpansionTile', 'CheckboxListTile', 'RadioListTile', 'SwitchListTile', + // Gesture / interaction + 'GestureDetector', 'InkWell', 'InkResponse', 'Dismissible', 'Draggable', 'DragTarget', + 'AbsorbPointer', 'IgnorePointer', 'Listener', 'MouseRegion', + // Builders + 'Builder', 'LayoutBuilder', 'StatefulBuilder', 'OrientationBuilder', 'FutureBuilder', + 'StreamBuilder', 'ValueListenableBuilder', 'AnimatedBuilder', 'NotificationListener', + // Animation + 'AnimatedContainer', 'AnimatedOpacity', 'AnimatedPadding', 'AnimatedPositioned', + 'AnimatedAlign', 'AnimatedDefaultTextStyle', 'AnimatedSwitcher', 'AnimatedCrossFade', + 'FadeTransition', 'ScaleTransition', 'RotationTransition', 'SlideTransition', + 'PositionedTransition', 'SizeTransition', 'TweenAnimationBuilder', 'Hero', + // Theme / inherited + 'Theme', 'CupertinoTheme', 'DefaultTextStyle', 'IconTheme', 'MediaQuery', + 'Directionality', 'Localizations', 'InheritedWidget', 'InheritedModel', + // Transforms / clipping + 'Transform', 'RotatedBox', 'ClipRRect', 'ClipRect', 'ClipOval', 'ClipPath', + 'CustomPaint', 'CustomMultiChildLayout', 'CustomSingleChildLayout', + // Navigation + 'Navigator', 'NavigatorState', 'Route', 'MaterialPageRoute', 'CupertinoPageRoute', + 'PageRouteBuilder', 'ModalRoute', 'WillPopScope', 'PopScope', + // Base classes (the framework provides these — user code extends them) + 'StatelessWidget', 'StatefulWidget', 'State', 'Widget', 'BuildContext', + 'PreferredSizeWidget', 'RenderObjectWidget', 'ProxyWidget', +]); diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 88bf205e6..7b151902e 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -25,6 +25,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { flutterResolver, flutterRouterResolver, flutterStateResolver } from './flutter'; /** * All registered framework resolvers @@ -66,6 +67,10 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // Dart / Flutter + flutterResolver, + flutterRouterResolver, + flutterStateResolver, ]; /** @@ -140,3 +145,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { flutterResolver, flutterRouterResolver, flutterStateResolver } from './flutter'; diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts index cead14864..2072b16a9 100644 --- a/src/resolution/strip-comments.ts +++ b/src/resolution/strip-comments.ts @@ -33,7 +33,8 @@ export type CommentLang = | 'csharp' | 'swift' | 'go' - | 'rust'; + | 'rust' + | 'dart'; export function stripCommentsForRegex(content: string, lang: CommentLang): string { switch (lang) { @@ -52,7 +53,8 @@ export function stripCommentsForRegex(content: string, lang: CommentLang): strin case 'java': case 'csharp': case 'swift': - return stripCStyle(content, /* allowSingleQuoteStrings */ lang === 'javascript' || lang === 'typescript'); + case 'dart': + return stripCStyle(content, /* allowSingleQuoteStrings */ lang === 'javascript' || lang === 'typescript' || lang === 'dart'); default: return content; }