diff --git a/examples/nexus_studio/app/lib/main.dart b/examples/nexus_studio/app/lib/main.dart index 1bf2bce..ccdf9d5 100644 --- a/examples/nexus_studio/app/lib/main.dart +++ b/examples/nexus_studio/app/lib/main.dart @@ -16,7 +16,7 @@ void main({ // Connect to LevitDevTools in debug mode if (kDebugMode && enableDevTools) { final transport = WebSocketTransport.connect( - 'ws://localhost:9200', + 'ws://localhost:9200/ws', appId: 'nexus-studio', channelBuilder: devToolsChannelBuilder, ); diff --git a/examples/nexus_studio/server/bin/server.dart b/examples/nexus_studio/server/bin/server.dart index 23a8c52..0313d69 100644 --- a/examples/nexus_studio/server/bin/server.dart +++ b/examples/nexus_studio/server/bin/server.dart @@ -6,7 +6,7 @@ import 'package:nexus_studio_shared/shared.dart'; void main() async { // Connect to LevitDevTools in debug mode final transport = WebSocketTransport.connect( - 'ws://localhost:9200', + 'ws://localhost:9200/ws', appId: 'nexus-server', ); diff --git a/packages/core/levit_flutter_core/lib/src/scoped_view.dart b/packages/core/levit_flutter_core/lib/src/scoped_view.dart index 327158b..cdecf7d 100644 --- a/packages/core/levit_flutter_core/lib/src/scoped_view.dart +++ b/packages/core/levit_flutter_core/lib/src/scoped_view.dart @@ -25,6 +25,7 @@ class LScopedView extends LView { this.dependencyFactory, super.resolver, super.builder, + super.orElse, super.autoWatch = true, this.scopeName, super.args, @@ -36,6 +37,7 @@ class LScopedView extends LView { Key? key, dynamic Function(LevitScope scope)? dependencyFactory, required Widget Function(BuildContext context, T controller) builder, + Widget Function(BuildContext context)? orElse, bool autoWatch = true, String? scopeName, List? args, @@ -43,8 +45,9 @@ class LScopedView extends LView { return LScopedView( key: key, dependencyFactory: dependencyFactory, - resolver: (context) => context.levit.find(key: state), + resolver: (context) => context.levit.findOrNull(key: state), builder: builder, + orElse: orElse, autoWatch: autoWatch, scopeName: scopeName, args: args, @@ -58,6 +61,7 @@ class LScopedView extends LView { String? tag, bool permanent = false, required Widget Function(BuildContext context, T controller) builder, + Widget Function(BuildContext context)? orElse, bool autoWatch = true, String? scopeName, List? args, @@ -68,8 +72,9 @@ class LScopedView extends LView { args: args, dependencyFactory: (s) => s.put(create, tag: tag, permanent: permanent), - resolver: (context) => context.levit.find(tag: tag), + resolver: (context) => context.levit.findOrNull(tag: tag), builder: builder, + orElse: orElse, autoWatch: autoWatch, ); } @@ -96,11 +101,17 @@ class _LScopedViewState extends State> { child: Builder( builder: (context) { final controller = - widget.resolver?.call(context) ?? context.levit.find(); + widget.resolver?.call(context) ?? context.levit.findOrNull(); + if (controller == null) { + final fallback = widget.orElse; + if (fallback != null) return fallback(context); + throw StateError( + 'LScopedView<$T>: controller not found and no orElse provided.', + ); + } if (widget.autoWatch) { return LWatch(() => widget.buildView(context, controller)); } - return LScope.runBridged( context, () => widget.buildView(context, controller)); }, diff --git a/packages/core/levit_flutter_core/lib/src/view.dart b/packages/core/levit_flutter_core/lib/src/view.dart index a41c612..58332f2 100644 --- a/packages/core/levit_flutter_core/lib/src/view.dart +++ b/packages/core/levit_flutter_core/lib/src/view.dart @@ -23,11 +23,19 @@ part of '../levit_flutter_core.dart'; /// ``` class LView extends StatefulWidget { /// Resolves the dependency from the context. - final T Function(BuildContext context)? resolver; + /// + /// Returns `null` when the dependency is not registered. + final T? Function(BuildContext context)? resolver; /// Builds the widget tree using the resolved [controller]. final Widget Function(BuildContext context, T controller)? builder; + /// Fallback widget when the controller cannot be resolved. + /// + /// If the resolution returns `null` and no [orElse] is provided, + /// a [StateError] is thrown. + final Widget Function(BuildContext context)? orElse; + /// Whether to wrap the view in an [LWatch]. final bool autoWatch; @@ -39,6 +47,7 @@ class LView extends StatefulWidget { super.key, this.resolver, this.builder, + this.orElse, this.autoWatch = true, this.args, }); @@ -48,13 +57,15 @@ class LView extends StatefulWidget { LevitStore state, { Key? key, required Widget Function(BuildContext context, T controller) builder, + Widget Function(BuildContext context)? orElse, bool autoWatch = true, List? args, }) { return LView( key: key, - resolver: (context) => context.levit.find(key: state), + resolver: (context) => context.levit.findOrNull(key: state), builder: builder, + orElse: orElse, autoWatch: autoWatch, args: args, ); @@ -67,6 +78,7 @@ class LView extends StatefulWidget { String? tag, bool permanent = false, required Widget Function(BuildContext context, T controller) builder, + Widget Function(BuildContext context)? orElse, bool autoWatch = true, List? args, }) { @@ -75,6 +87,7 @@ class LView extends StatefulWidget { resolver: (context) => context.levit.put(create, tag: tag, permanent: permanent), builder: builder, + orElse: orElse, autoWatch: autoWatch, args: args, ); @@ -102,7 +115,7 @@ class LView extends StatefulWidget { } class _LViewState extends State> { - late T controller; + T? controller; LevitScope? _boundScope; @override @@ -136,8 +149,8 @@ class _LViewState extends State> { } } - T _resolveController() { - return widget.resolver?.call(context) ?? context.levit.find(); + T? _resolveController() { + return widget.resolver?.call(context) ?? context.levit.findOrNull(); } bool _argsMatch(List? a, List? b) { @@ -151,13 +164,18 @@ class _LViewState extends State> { @override Widget build(BuildContext context) { - // Single dispatch path supports both builder callback and subclass override. + final c = controller; + if (c == null) { + final fallback = widget.orElse; + if (fallback != null) return fallback(context); + throw StateError( + 'LView<$T>: controller not found and no orElse provided.', + ); + } if (widget.autoWatch) { - return LWatch(() => widget.buildView(context, controller)); + return LWatch(() => widget.buildView(context, c)); } - - return LScope.runBridged( - context, () => widget.buildView(context, controller)); + return LScope.runBridged(context, () => widget.buildView(context, c)); } } diff --git a/packages/core/levit_flutter_core/lib/src/watch.dart b/packages/core/levit_flutter_core/lib/src/watch.dart index 8211dc8..b9f0383 100644 --- a/packages/core/levit_flutter_core/lib/src/watch.dart +++ b/packages/core/levit_flutter_core/lib/src/watch.dart @@ -25,7 +25,8 @@ class LWatch extends Widget { Element createElement() => _LWatchElement(this); } -class _LWatchElement extends ComponentElement implements LevitReactiveObserver { +class _LWatchElement extends ComponentElement + implements LevitReactiveObserver, LevitReactiveReadResolver { _LWatchElement(LWatch super.widget); @override @@ -63,6 +64,53 @@ class _LWatchElement extends ComponentElement implements LevitReactiveObserver { // Graph-only tracking is not required for widget rebuild subscriptions. } + @override + LxReactive resolveReactiveRead(LxReactive reactive) { + // Stabilize reads when a getter recreates wrapper reactives every build + // but they still represent the same logical source. + final single = _singleNotifier; + if (_usingSinglePath && single is LxReactive) { + final stable = single as LxReactive; + if (_isSameLogicalReactive(stable, reactive)) { + return stable; + } + } + + final nots = _notifiers; + if (nots == null || nots.isEmpty) return reactive; + + LxReactive? match; + for (final notifier in nots) { + if (notifier is! LxReactive) continue; + final candidate = notifier as LxReactive; + if (!_isSameLogicalReactive(candidate, reactive)) continue; + + if (match != null && !identical(match, candidate)) { + return reactive; // Ambiguous alias, keep the original read. + } + match = candidate; + } + + return match ?? reactive; + } + + bool _isSameLogicalReactive(LxReactive a, LxReactive b) { + if (identical(a, b)) return true; + if (a.runtimeType != b.runtimeType) return false; + + // Keep aliasing conservative to avoid cross-wiring unrelated reactives. + final ownerA = a.ownerId; + final ownerB = b.ownerId; + final nameA = a.name; + final nameB = b.name; + + if (ownerA == null || ownerB == null || nameA == null || nameB == null) { + return false; + } + + return ownerA == ownerB && nameA == nameB; + } + void _cleanupAll() { if (_usingSinglePath) { _singleNotifier?.removeListener(_triggerRebuild); @@ -99,6 +147,10 @@ class _LWatchElement extends ComponentElement implements LevitReactiveObserver { _newNotifiers = null; final previousProxy = Lx.proxy; + if (identical(previousProxy, this)) { + // Force-reset read dedupe cache for a fresh dependency capture cycle. + Lx.proxy = null; + } Lx.proxy = this; final Widget result; diff --git a/packages/core/levit_flutter_core/test/scope/levit_provider_resolves_store_test.dart b/packages/core/levit_flutter_core/test/scope/levit_provider_resolves_store_test.dart new file mode 100644 index 0000000..cac87dd --- /dev/null +++ b/packages/core/levit_flutter_core/test/scope/levit_provider_resolves_store_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +void main() { + testWidgets('LevitProvider resolves LevitStore using context.levit.find', + (tester) async { + final store = LevitStore((ref) => 'store_value'); + String? resolvedValue; + + await tester.pumpWidget( + MaterialApp( + home: LScope( + child: Builder( + builder: (context) { + // This calls LevitProvider.find with key: store + resolvedValue = context.levit.find(key: store); + return Text(resolvedValue ?? 'null'); + }, + ), + ), + ), + ); + + expect(resolvedValue, 'store_value'); + expect(find.text('store_value'), findsOneWidget); + }); +} diff --git a/packages/core/levit_flutter_core/test/view/lview_renders_or_else_when_controller_null_test.dart b/packages/core/levit_flutter_core/test/view/lview_renders_or_else_when_controller_null_test.dart new file mode 100644 index 0000000..5a22845 --- /dev/null +++ b/packages/core/levit_flutter_core/test/view/lview_renders_or_else_when_controller_null_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +void main() { + testWidgets('LView renders orElse when controller is null', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LView( + resolver: (context) => null, // Explicitly return null + orElse: (context) => const Text('Fallback UI'), + builder: (context, controller) => Text('Success: $controller'), + ), + ), + ); + + expect(find.text('Fallback UI'), findsOneWidget); + expect(find.text('Success'), findsNothing); + }); +} diff --git a/packages/core/levit_flutter_core/test/view/scoped_view_renders_or_else_when_controller_null_test.dart b/packages/core/levit_flutter_core/test/view/scoped_view_renders_or_else_when_controller_null_test.dart new file mode 100644 index 0000000..7ec35b8 --- /dev/null +++ b/packages/core/levit_flutter_core/test/view/scoped_view_renders_or_else_when_controller_null_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +void main() { + testWidgets('LScopedView renders orElse when controller is null', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LScopedView( + // No dependency factory, so context.levit.findOrNull() returns null + orElse: (context) => const Text('Scoped Fallback UI'), + builder: (context, controller) => Text('Success: $controller'), + ), + ), + ); + + expect(find.text('Scoped Fallback UI'), findsOneWidget); + expect(find.text('Success'), findsNothing); + }); +} diff --git a/packages/core/levit_flutter_core/test/view/scoped_view_throws_when_controller_null_test.dart b/packages/core/levit_flutter_core/test/view/scoped_view_throws_when_controller_null_test.dart new file mode 100644 index 0000000..d4946af --- /dev/null +++ b/packages/core/levit_flutter_core/test/view/scoped_view_throws_when_controller_null_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +void main() { + testWidgets( + 'LScopedView throws StateError when controller is null and no orElse provided', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LScopedView( + builder: (context, controller) => Text('Success: $controller'), + ), + ), + ); + + expect(tester.takeException(), isInstanceOf()); + }); +} diff --git a/packages/core/levit_flutter_core/test/view/test_lscoped_view_nullable_resolution.dart b/packages/core/levit_flutter_core/test/view/test_lscoped_view_nullable_resolution.dart new file mode 100644 index 0000000..2fcaef3 --- /dev/null +++ b/packages/core/levit_flutter_core/test/view/test_lscoped_view_nullable_resolution.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +import '../helpers.dart'; + +void main() { + setUp(() { + Levit.reset(force: true); + }); + + group('LScopedView orElse', () { + testWidgets('renders builder when dependencyFactory registers controller', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LScopedView.put( + () => TestController()..count = 10, + builder: (context, controller) => + Text('Count: ${controller.count}'), + orElse: (context) => const Text('Fallback'), + ), + ), + ); + + expect(find.text('Count: 10'), findsOneWidget); + expect(find.text('Fallback'), findsNothing); + }); + + testWidgets('renders orElse when no matching controller in scope', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LScopedView( + builder: (context, controller) => const Text('Found'), + orElse: (context) => const Text('Not available'), + ), + ), + ); + + expect(find.text('Not available'), findsOneWidget); + expect(find.text('Found'), findsNothing); + }); + + testWidgets('throws StateError when missing and no orElse', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LScopedView( + builder: (context, controller) => const Text('Found'), + ), + ), + ); + + expect(tester.takeException(), isA()); + }); + + testWidgets('disposes controller from scope on unmount', (tester) async { + final showWidget = ValueNotifier(true); + TestController? captured; + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: showWidget, + builder: (context, show, __) => show + ? LScopedView.put( + () => TestController(), + builder: (context, controller) { + captured = controller; + return Text('Count: ${controller.count}'); + }, + orElse: (context) => const Text('Fallback'), + ) + : const Text('Hidden'), + ), + ), + ); + + expect(find.text('Count: 0'), findsOneWidget); + expect(captured?.closeCalled, isFalse); + + showWidget.value = false; + await tester.pump(); + + expect(find.text('Hidden'), findsOneWidget); + expect(captured?.closeCalled, isTrue); + }); + }); +} diff --git a/packages/core/levit_flutter_core/test/view/test_lview_nullable_resolution.dart b/packages/core/levit_flutter_core/test/view/test_lview_nullable_resolution.dart new file mode 100644 index 0000000..6dc3dd1 --- /dev/null +++ b/packages/core/levit_flutter_core/test/view/test_lview_nullable_resolution.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +import '../helpers.dart'; + +void main() { + setUp(() { + Levit.reset(force: true); + }); + + group('LView orElse', () { + testWidgets('renders builder when controller is registered', + (tester) async { + Levit.put(() => TestController()..count = 7); + + await tester.pumpWidget( + MaterialApp( + home: LView( + builder: (context, controller) => + Text('Count: ${controller.count}'), + orElse: (context) => const Text('Fallback'), + autoWatch: false, + ), + ), + ); + + expect(find.text('Count: 7'), findsOneWidget); + expect(find.text('Fallback'), findsNothing); + }); + + testWidgets('renders orElse when controller is not registered', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LView( + builder: (context, controller) => const Text('Found'), + orElse: (context) => const Text('Not available'), + autoWatch: false, + ), + ), + ); + + expect(find.text('Not available'), findsOneWidget); + expect(find.text('Found'), findsNothing); + }); + + testWidgets('throws StateError when controller missing and no orElse', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: LView( + builder: (context, controller) => const Text('Found'), + autoWatch: false, + ), + ), + ); + + expect(tester.takeException(), isA()); + }); + + testWidgets('reactive updates work with autoWatch and orElse', + (tester) async { + final controller = TestController(); + Levit.put(() => controller); + + await tester.pumpWidget( + MaterialApp( + home: LView( + builder: (context, c) => Text('Reactive: ${c.reactiveCount.value}'), + orElse: (context) => const Text('Fallback'), + ), + ), + ); + + expect(find.text('Reactive: 0'), findsOneWidget); + + controller.reactiveCount.value = 99; + await tester.pump(); + + expect(find.text('Reactive: 99'), findsOneWidget); + }); + + testWidgets('renders builder with tagged controller', (tester) async { + Levit.put(() => TestController()..count = 55, tag: 'special'); + + await tester.pumpWidget( + MaterialApp( + home: LView( + resolver: (context) => + context.levit.findOrNull(tag: 'special'), + builder: (context, controller) => + Text('Tagged: ${controller.count}'), + orElse: (context) => const Text('Not found'), + autoWatch: false, + ), + ), + ); + + expect(find.text('Tagged: 55'), findsOneWidget); + }); + + testWidgets('renders orElse with wrong tag', (tester) async { + Levit.put(() => TestController()..count = 55, tag: 'special'); + + await tester.pumpWidget( + MaterialApp( + home: LView( + resolver: (context) => + context.levit.findOrNull(tag: 'wrong'), + builder: (context, controller) => const Text('Found'), + orElse: (context) => const Text('Wrong tag'), + autoWatch: false, + ), + ), + ); + + expect(find.text('Wrong tag'), findsOneWidget); + }); + }); +} diff --git a/packages/core/levit_flutter_core/test/watch/watch_resets_proxy_on_reentrant_build_test.dart b/packages/core/levit_flutter_core/test/watch/watch_resets_proxy_on_reentrant_build_test.dart new file mode 100644 index 0000000..0166fcb --- /dev/null +++ b/packages/core/levit_flutter_core/test/watch/watch_resets_proxy_on_reentrant_build_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:levit_flutter_core/levit_flutter_core.dart'; + +void main() { + testWidgets('LWatch re-entrant build proxy reset', (tester) async { + final watchKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: LWatch( + () => Container(), + key: watchKey, + ), + ), + ); + + // Get the element associated with LWatch + final element = tester.element(find.byKey(watchKey)) as ComponentElement; + + // Simulate a re-entrant build by setting Lx.proxy to the element itself + // and explicitly invoking build. This is unnatural in typical Flutter but + // satisfies the identical(previousProxy, this) branch in watch.dart line 150. + Lx.proxy = element as LevitReactiveObserver; + + expect( + // ignore: invalid_use_of_protected_member + () => element.build(), + returnsNormally, + ); + + // proxy should be correctly restored to the previous value + expect(Lx.proxy, equals(element)); + }); +} diff --git a/packages/core/levit_flutter_core/test/watch/watch_test.dart b/packages/core/levit_flutter_core/test/watch/watch_test.dart index e74fbab..95e8d55 100644 --- a/packages/core/levit_flutter_core/test/watch/watch_test.dart +++ b/packages/core/levit_flutter_core/test/watch/watch_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:levit_flutter_core/levit_flutter_core.dart'; @@ -90,6 +92,71 @@ void main() { expect(find.text('Count: 1'), findsOneWidget); }); + + testWidgets('dedupes duplicate LxStream emits when reading lastValue', + (tester) async { + final controller = StreamController.broadcast(); + final stream = LxStream(controller.stream); + var buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: LWatch(() { + buildCount++; + final last = stream.value.lastValue; + return Text('Last: ${last ?? -1}'); + }), + ), + ); + + expect(buildCount, 1); + expect(find.text('Last: -1'), findsOneWidget); + + controller.add(1); + await tester.pump(); + await tester.pump(); + expect(buildCount, 2); + expect(find.text('Last: 1'), findsOneWidget); + + controller.add(1); // Duplicate payload should still rebuild. + await tester.pump(); + await tester.pump(); + expect(buildCount, 2); + expect(find.text('Last: 1'), findsOneWidget); + + await controller.close(); + }); + + testWidgets( + 'stabilizes logical dependency when getter recreates named reactive', + (tester) async { + final source = _UnstableNamedStreamSource(); + + await tester.pumpWidget( + MaterialApp( + home: LWatch(() { + final last = source.mediaLibrary.value.lastValue; + return Text('Last: ${last ?? -1}'); + }), + ), + ); + + expect(find.text('Last: -1'), findsOneWidget); + + source.add(1); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(find.text('Last: 1'), findsOneWidget); + + source.add(2); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(find.text('Last: 2'), findsOneWidget); + + await source.close(); + }); }); group('LWatchVar', () { @@ -159,3 +226,21 @@ void main() { }); }); } + +class _UnstableNamedStreamSource { + static const _ownerId = 'test-owner'; + static const _name = 'mediaLibrary'; + + final StreamController _controller = StreamController.broadcast(); + + LxStream get mediaLibrary { + final reactive = LxStream(_controller.stream); + reactive.ownerId = _ownerId; + reactive.name = _name; + return reactive; + } + + void add(int value) => _controller.add(value); + + Future close() => _controller.close(); +} diff --git a/packages/core/levit_reactive/lib/src/async_types.dart b/packages/core/levit_reactive/lib/src/async_types.dart index 3d1e91d..2820153 100644 --- a/packages/core/levit_reactive/lib/src/async_types.dart +++ b/packages/core/levit_reactive/lib/src/async_types.dart @@ -34,7 +34,7 @@ class LxStream extends _LxAsyncVal { onListen: null, onCancel: null); void _bind(Stream stream, {bool isInitial = true}) { - final lastKnown = value.lastValue; + final lastKnown = _value.lastValue; if (!isInitial) { _setValueInternal(LxWaiting(lastKnown)); @@ -47,7 +47,7 @@ class LxStream extends _LxAsyncVal { sink.add(LxSuccess(data)); }, handleError: (error, stackTrace, sink) { - sink.add(LxError(error, stackTrace, value.lastValue)); + sink.add(LxError(error, stackTrace, _value.lastValue)); }, ), ) @@ -173,9 +173,9 @@ class LxFuture extends _LxAsyncVal { void _run(Future future, {bool isRefresh = false}) { _activeFuture = future; - final lastKnown = value.lastValue; + final lastKnown = _value.lastValue; - if (isRefresh || value is! LxSuccess) { + if (isRefresh || _value is! LxSuccess) { _setValueInternal(LxWaiting(lastKnown)); } diff --git a/packages/core/levit_reactive/lib/src/computed.dart b/packages/core/levit_reactive/lib/src/computed.dart index d9d08ab..b8f7bf3 100644 --- a/packages/core/levit_reactive/lib/src/computed.dart +++ b/packages/core/levit_reactive/lib/src/computed.dart @@ -390,7 +390,7 @@ class LxAsyncComputed extends _ComputedBase> { if (_isClosed || !_isActive) return; final myExecutionId = ++_executionId; - final lastKnown = value.lastValue; + final lastKnown = _value.lastValue; final isInitial = !_hasProducedResult; // Dynamic async runs rebuild subscriptions; static graphs keep existing links. @@ -499,7 +499,7 @@ class LxAsyncComputed extends _ComputedBase> { void _applyResult(T result, {required bool isInitial}) { if (!isInitial && _hasValue && _equals(_lastComputedValue as T, result)) { // Preserve reactive update semantics when value is unchanged after waiting. - if (value is LxWaiting) { + if (_value is LxWaiting) { _setValueInternal(LxSuccess(result)); } return; diff --git a/packages/core/levit_reactive/lib/src/core.dart b/packages/core/levit_reactive/lib/src/core.dart index f5f8066..98010f5 100644 --- a/packages/core/levit_reactive/lib/src/core.dart +++ b/packages/core/levit_reactive/lib/src/core.dart @@ -301,6 +301,11 @@ abstract class LevitReactiveObserver { void addReactive(LxReactive reactive) {} } +/// Optional interface for observers that can canonicalize reactive reads. +abstract interface class LevitReactiveReadResolver { + LxReactive resolveReactiveRead(LxReactive reactive); +} + /// A low-latency notifier for synchronous reactive updates. /// /// [LevitReactiveNotifier] is the high-performance core of the reactive system. @@ -618,12 +623,28 @@ abstract class LxBase extends LevitReactiveNotifier final proxy = _LevitReactiveCore.proxy; if (proxy != null) { - _reportRead(proxy); + LxReactive resolved = this; + if (proxy is LevitReactiveReadResolver) { + resolved = + (proxy as LevitReactiveReadResolver).resolveReactiveRead(this); + } + if (!identical(resolved, this) && resolved is LxReactive) { + return resolved.value; + } + _reportRead(proxy, resolvedReactive: resolved); } else if (_LevitReactiveCore._asyncZoneDepth > 0) { final zoneTracker = Zone.current[_LevitReactiveCore.asyncComputedTrackerZoneKey]; if (zoneTracker is LevitReactiveObserver) { - _reportRead(zoneTracker); + LxReactive resolved = this; + if (zoneTracker is LevitReactiveReadResolver) { + resolved = (zoneTracker as LevitReactiveReadResolver) + .resolveReactiveRead(this); + } + if (!identical(resolved, this) && resolved is LxReactive) { + return resolved.value; + } + _reportRead(zoneTracker, resolvedReactive: resolved); } } return _value; @@ -638,20 +659,27 @@ abstract class LxBase extends LevitReactiveNotifier super.notify(); } - void _reportRead(LevitReactiveObserver observer) { - if (identical(this, _LevitReactiveCore._lastReportedReactive) && + void _reportRead(LevitReactiveObserver observer, + {LxReactive? resolvedReactive}) { + final trackedReactive = resolvedReactive ?? this; + final LevitReactiveNotifier trackedNotifier = + trackedReactive is LevitReactiveNotifier + ? trackedReactive as LevitReactiveNotifier + : this; + + if (identical(trackedReactive, _LevitReactiveCore._lastReportedReactive) && identical(observer, _LevitReactiveCore._lastReportedObserver)) { return; } _LevitReactiveCore._lastReportedObserver = observer; - _LevitReactiveCore._lastReportedReactive = this; + _LevitReactiveCore._lastReportedReactive = trackedReactive; - observer.addNotifier(this); + observer.addNotifier(trackedNotifier); // Reactive graph reporting is opt-in to avoid overhead by default. if (LevitReactiveMiddleware.hasGraphChangeMiddlewares) { - observer.addReactive(this); + observer.addReactive(trackedReactive); } } diff --git a/packages/core/levit_reactive/test/core/read_resolver_delegates_to_different_reactive_test.dart b/packages/core/levit_reactive/test/core/read_resolver_delegates_to_different_reactive_test.dart new file mode 100644 index 0000000..439d2d5 --- /dev/null +++ b/packages/core/levit_reactive/test/core/read_resolver_delegates_to_different_reactive_test.dart @@ -0,0 +1,66 @@ +import 'package:test/test.dart'; +import 'dart:async'; +import 'package:levit_reactive/levit_reactive.dart'; +// Note: test should not import src/core.dart if possible, but Lx provides what we need + +class _ResolverObserver + implements LevitReactiveObserver, LevitReactiveReadResolver { + final LxReactive original; + final LxReactive replacement; + LxReactive? observed; + + _ResolverObserver(this.original, this.replacement); + + @override + void addNotifier(LevitReactiveNotifier notifier) {} + + @override + void addReactive(LxReactive reactive) { + observed = reactive; + } + + @override + LxReactive resolveReactiveRead(LxReactive reactive) { + if (identical(reactive, original)) return replacement; + return reactive; + } +} + +void main() { + test('LevitReactiveReadResolver via proxy delegates read', () { + final original = 1.lx; + final replacement = 2.lx; + final observer = _ResolverObserver(original, replacement); + + Lx.proxy = observer; + // Reading original should hit proxy and defer to replacement.value + final val = original.value; + Lx.proxy = null; + + expect(val, 2); + // When graphing is enabled (which it typically is not explicitly here without middleware), + // addReactive would be called, but the main thing is it didn't throw and returned 2. + }); + + test('LevitReactiveReadResolver via zoneTracker delegates read', () { + final original = 10.lx; + final replacement = 20.lx; + final observer = _ResolverObserver(original, replacement); + + int? resolvedVal; + + // Simulate _asyncZoneDepth > 0 + Lx.enterAsyncScope(); + runZoned( + () { + resolvedVal = original.value; // Hits lines 640-645 in core.dart + }, + zoneValues: { + Lx.asyncComputedTrackerZoneKey: observer, + }, + ); + Lx.exitAsyncScope(); + + expect(resolvedVal, 20); + }); +} diff --git a/packages/kits/levit/README.md b/packages/kits/levit/README.md index 10f9652..bd44dc6 100644 --- a/packages/kits/levit/README.md +++ b/packages/kits/levit/README.md @@ -3,7 +3,7 @@ [![Pub Version](https://img.shields.io/pub/v/levit)](https://pub.dev/packages/levit) [![Platforms](https://img.shields.io/badge/platforms-dart-blue)](https://pub.dev/packages/levit) [![License: MIT](https://img.shields.io/badge/license-MIT-purple.svg)](https://opensource.org/licenses/MIT) -[![codecov](https://codecov.io/gh/atoumbre/levit/graph/badge.svg?token=AESOtS4YPg&flags=levit)](https://codecov.io/github/atoumbre/levit) +[![codecov](https://codecov.io/gh/atoumbre/levit/graph/badge.svg?token=AESOtS4YPg&flags=levit_dart)](https://codecov.io/github/atoumbre/levit) ## Purpose & Scope diff --git a/packages/kits/levit_dart/README.md b/packages/kits/levit_dart/README.md index 87c607f..7b6764b 100644 --- a/packages/kits/levit_dart/README.md +++ b/packages/kits/levit_dart/README.md @@ -3,6 +3,7 @@ [![Pub Version](https://img.shields.io/pub/v/levit_dart)](https://pub.dev/packages/levit_dart) [![Platforms](https://img.shields.io/badge/platforms-dart-blue)](https://pub.dev/packages/levit_dart) [![License: MIT](https://img.shields.io/badge/license-MIT-purple.svg)](https://opensource.org/licenses/MIT) +[![codecov](https://codecov.io/gh/atoumbre/levit/graph/badge.svg?token=AESOtS4YPg&flags=levit_dart)](https://codecov.io/github/atoumbre/levit) ## Purpose & Scope diff --git a/packages/kits/levit_flutter/README.md b/packages/kits/levit_flutter/README.md index e38bfb8..13b1900 100644 --- a/packages/kits/levit_flutter/README.md +++ b/packages/kits/levit_flutter/README.md @@ -3,6 +3,7 @@ [![Pub Version](https://img.shields.io/pub/v/levit_flutter)](https://pub.dev/packages/levit_flutter) [![Platforms](https://img.shields.io/badge/platforms-flutter-blue)](https://pub.dev/packages/levit_flutter) [![License: MIT](https://img.shields.io/badge/license-MIT-purple.svg)](https://opensource.org/licenses/MIT) +[![codecov](https://codecov.io/gh/atoumbre/levit/graph/badge.svg?token=AESOtS4YPg&flags=levit_flutter)](https://codecov.io/github/atoumbre/levit) ## Purpose & Scope diff --git a/tooling/levit_mcp_server/README.md b/tooling/levit_mcp_server/README.md deleted file mode 100644 index 90be838..0000000 --- a/tooling/levit_mcp_server/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# levit_mcp_server - -`levit_mcp_server` is a stdio-based MCP server for Levit. - -## Features - -- Implements MCP JSON-RPC over stdio (`initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/read`). -- Exposes Levit-oriented tools: - - `levit_workspace_scan`: scan workspace packages. - - `levit_api_lookup`: find Dart symbol references in Levit package source. - - `levit_docs_search`: search README/CHANGELOG docs. - - `levit_affected_packages`: infer affected packages from changed paths or git status. - - `levit_analyze_packages`: dry-run or run `dart analyze` on selected/affected packages. - - `levit_reactive_simulate`: run a deterministic reactive simulation. -- Exposes MCP resources: - - `levit://workspace/packages` - - `levit://workspace/affected_packages` - -## Run - -From this package directory: - -```bash -dart run bin/levit_mcp_server.dart -``` - -## Configure in an MCP client - -Example command: - -```json -{ - "command": "dart", - "args": ["run", "bin/levit_mcp_server.dart"], - "cwd": "/Users/atoumbre/SoftiLab/levit-svg/packages/tooling/levit_mcp_server" -} -``` diff --git a/tooling/levit_mcp_server/analysis_options.yaml b/tooling/levit_mcp_server/analysis_options.yaml deleted file mode 100644 index 572dd23..0000000 --- a/tooling/levit_mcp_server/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:lints/recommended.yaml diff --git a/tooling/levit_mcp_server/bin/levit_mcp_server.dart b/tooling/levit_mcp_server/bin/levit_mcp_server.dart deleted file mode 100644 index d8aa41f..0000000 --- a/tooling/levit_mcp_server/bin/levit_mcp_server.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'dart:io'; - -import 'package:levit_mcp_server/levit_mcp_server.dart'; - -Future main() async { - final server = LevitMcpServer(); - await server.serve(stdin, stdout); -} diff --git a/tooling/levit_mcp_server/lib/levit_mcp_server.dart b/tooling/levit_mcp_server/lib/levit_mcp_server.dart deleted file mode 100644 index fbaa204..0000000 --- a/tooling/levit_mcp_server/lib/levit_mcp_server.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'src/server.dart'; -export 'src/tools.dart'; diff --git a/tooling/levit_mcp_server/lib/src/server.dart b/tooling/levit_mcp_server/lib/src/server.dart deleted file mode 100644 index ef1a393..0000000 --- a/tooling/levit_mcp_server/lib/src/server.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:levit_mcp_server/src/tools.dart'; - -class LevitMcpServer { - LevitMcpServer({LevitToolRegistry? registry}) - : _registry = registry ?? LevitToolRegistry(); - - final LevitToolRegistry _registry; - - Future serve(Stream> input, IOSink output) async { - final parser = _StdioMessageParser(); - - await for (final chunk in input) { - final payloads = parser.push(chunk); - for (final payload in payloads) { - await _handlePayload(payload, output); - } - } - } - - Future _handlePayload(String payload, IOSink output) async { - Map request; - - try { - final decoded = jsonDecode(payload); - if (decoded is! Map) { - return; - } - request = decoded; - } catch (_) { - return; - } - - final method = request['method']; - final id = request['id']; - - if (method is! String) { - if (id != null) { - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32600, - 'message': 'Invalid Request', - }, - }, - ); - } - return; - } - - if (method == 'notifications/initialized') { - return; - } - - final params = request['params']; - final paramMap = - params is Map ? params : {}; - - switch (method) { - case 'initialize': - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'protocolVersion': '2024-11-05', - 'capabilities': { - 'tools': {}, - 'resources': {}, - }, - 'serverInfo': { - 'name': 'levit_mcp_server', - 'version': '0.1.0', - }, - }, - }, - ); - - case 'ping': - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': {}, - }, - ); - - case 'tools/list': - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'tools': - _registry.listTools().map((tool) => tool.toJson()).toList(), - }, - }, - ); - - case 'tools/call': - final toolName = paramMap['name']; - final arguments = paramMap['arguments']; - - if (toolName is! String || arguments is! Map) { - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32602, - 'message': 'Invalid params for tools/call', - }, - }, - ); - return; - } - - final toolResult = await _registry.call(toolName, arguments); - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': toolResult.toMcpResult(), - }, - ); - - case 'resources/list': - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'resources': _registry - .listResources() - .map((resource) => resource.toJson()) - .toList(growable: false), - }, - }, - ); - - case 'resources/read': - final uri = paramMap['uri']; - if (uri is! String || uri.trim().isEmpty) { - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32602, - 'message': 'Invalid params for resources/read', - }, - }, - ); - return; - } - - try { - final resourceResult = await _registry.readResource(uri); - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'result': resourceResult, - }, - ); - } on ArgumentError catch (error) { - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32602, - 'message': error.message, - }, - }, - ); - } - - default: - if (id == null) { - return; - } - _writeJsonRpc( - output, - { - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32601, - 'message': 'Method not found: $method', - }, - }, - ); - } - } - - void _writeJsonRpc(IOSink output, Map message) { - final body = utf8.encode(jsonEncode(message)); - output - ..write('Content-Length: ${body.length}\r\n\r\n') - ..add(body) - ..flush(); - } -} - -class _StdioMessageParser { - final List _buffer = []; - - List push(List chunk) { - _buffer.addAll(chunk); - final messages = []; - - while (true) { - final headerEnd = _indexOfHeaderEnd(_buffer); - if (headerEnd < 0) { - break; - } - - final headerBytes = _buffer.sublist(0, headerEnd); - final headerText = ascii.decode(headerBytes, allowInvalid: true); - final length = _readContentLength(headerText); - - if (length == null) { - _buffer.clear(); - break; - } - - final messageStart = headerEnd + 4; - final messageEnd = messageStart + length; - if (_buffer.length < messageEnd) { - break; - } - - final bodyBytes = _buffer.sublist(messageStart, messageEnd); - messages.add(utf8.decode(bodyBytes)); - _buffer.removeRange(0, messageEnd); - } - - return messages; - } - - int _indexOfHeaderEnd(List data) { - for (var i = 0; i < data.length - 3; i++) { - if (data[i] == 13 && - data[i + 1] == 10 && - data[i + 2] == 13 && - data[i + 3] == 10) { - return i; - } - } - return -1; - } - - int? _readContentLength(String headerText) { - final lines = headerText.split('\r\n'); - for (final line in lines) { - final parts = line.split(':'); - if (parts.length < 2) { - continue; - } - - final name = parts.first.trim().toLowerCase(); - if (name != 'content-length') { - continue; - } - - return int.tryParse(parts.sublist(1).join(':').trim()); - } - - return null; - } -} diff --git a/tooling/levit_mcp_server/lib/src/tools.dart b/tooling/levit_mcp_server/lib/src/tools.dart deleted file mode 100644 index 3acbad8..0000000 --- a/tooling/levit_mcp_server/lib/src/tools.dart +++ /dev/null @@ -1,733 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:levit_reactive/levit_reactive.dart'; - -typedef ToolHandler = Future Function( - Map args); - -class McpTool { - const McpTool({ - required this.name, - required this.description, - required this.inputSchema, - required this.handler, - }); - - final String name; - final String description; - final Map inputSchema; - final ToolHandler handler; - - Map toJson() { - return { - 'name': name, - 'description': description, - 'inputSchema': inputSchema, - }; - } -} - -class McpResource { - const McpResource({ - required this.uri, - required this.name, - required this.mimeType, - required this.description, - }); - - final String uri; - final String name; - final String mimeType; - final String description; - - Map toJson() { - return { - 'uri': uri, - 'name': name, - 'mimeType': mimeType, - 'description': description, - }; - } -} - -class ToolCallResult { - const ToolCallResult({ - required this.message, - this.structured, - this.isError = false, - }); - - final String message; - final Map? structured; - final bool isError; - - Map toMcpResult() { - final result = { - 'content': >[ - {'type': 'text', 'text': message}, - ], - 'isError': isError, - }; - - if (structured != null) { - result['structuredContent'] = structured; - } - - return result; - } -} - -class LevitToolRegistry { - LevitToolRegistry({Directory? workspaceDirectory}) - : _workspaceDirectory = workspaceDirectory ?? Directory.current { - _tools = { - _simulateReactiveTool.name: _simulateReactiveTool, - _scanWorkspaceTool.name: _scanWorkspaceTool, - _apiLookupTool.name: _apiLookupTool, - _docsSearchTool.name: _docsSearchTool, - _affectedPackagesTool.name: _affectedPackagesTool, - _analyzePackagesTool.name: _analyzePackagesTool, - }; - } - - final Directory _workspaceDirectory; - late final Map _tools; - - List listTools() { - final tools = _tools.values.toList(growable: false); - tools.sort((a, b) => a.name.compareTo(b.name)); - return tools; - } - - List listResources() { - return const [ - McpResource( - uri: 'levit://workspace/packages', - name: 'Workspace Packages', - mimeType: 'application/json', - description: 'Discovered Levit workspace packages and paths.', - ), - McpResource( - uri: 'levit://workspace/affected_packages', - name: 'Affected Packages', - mimeType: 'application/json', - description: 'Packages inferred from current changed files.', - ), - ]; - } - - Future> readResource(String uri) async { - switch (uri) { - case 'levit://workspace/packages': - final result = await _scanWorkspaceTool.handler({}); - return { - 'contents': >[ - { - 'uri': uri, - 'mimeType': 'application/json', - 'text': result.message, - }, - ], - }; - case 'levit://workspace/affected_packages': - final result = await _affectedPackagesTool.handler({}); - return { - 'contents': >[ - { - 'uri': uri, - 'mimeType': 'application/json', - 'text': result.message, - }, - ], - }; - default: - throw ArgumentError('Unknown resource URI: $uri'); - } - } - - Future call(String name, Map args) async { - final tool = _tools[name]; - if (tool == null) { - return ToolCallResult( - message: 'Unknown tool: $name', - isError: true, - ); - } - - try { - return await tool.handler(args); - } catch (error, stackTrace) { - final payload = jsonEncode({ - 'error': error.toString(), - 'stackTrace': stackTrace.toString(), - }); - return ToolCallResult( - message: 'Tool failed: $payload', - isError: true, - ); - } - } - - McpTool get _simulateReactiveTool => McpTool( - name: 'levit_reactive_simulate', - description: - 'Run a small deterministic reactive simulation using levit_reactive.', - inputSchema: { - 'type': 'object', - 'properties': { - 'initial': { - 'type': 'integer', - 'description': 'Initial counter value.', - 'default': 0, - }, - 'updates': { - 'type': 'array', - 'description': 'Delta updates applied sequentially.', - 'items': {'type': 'integer'}, - 'default': [1, 1, 1], - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final initial = _readInt(args['initial'], fallback: 0); - final updates = - _readIntList(args['updates'], fallback: [1, 1, 1]); - - final count = initial.lx; - final doubled = LxComputed(() => count() * 2); - final history = >[]; - - try { - for (final delta in updates) { - count(count() + delta); - history.add({ - 'count': count(), - 'doubled': doubled(), - }); - } - - final result = { - 'initial': initial, - 'updates': updates, - 'finalCount': count(), - 'finalDoubled': doubled(), - 'history': history, - }; - - return ToolCallResult( - message: jsonEncode(result), structured: result); - } finally { - doubled.close(); - count.close(); - } - }, - ); - - McpTool get _scanWorkspaceTool => McpTool( - name: 'levit_workspace_scan', - description: - 'Scan workspace packages under packages/*/* and return Levit package metadata.', - inputSchema: { - 'type': 'object', - 'properties': { - 'rootPath': { - 'type': 'string', - 'description': - 'Optional workspace root path. Defaults to server cwd.', - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final root = _resolveRoot(args['rootPath'] as String?); - final packages = _discoverPackages(root); - - final result = { - 'rootPath': root.path, - 'count': packages.length, - 'packages': - packages.map((pkg) => pkg.toJson()).toList(growable: false), - }; - - return ToolCallResult( - message: jsonEncode(result), structured: result); - }, - ); - - McpTool get _apiLookupTool => McpTool( - name: 'levit_api_lookup', - description: - 'Lookup symbol references in Dart source files across Levit packages.', - inputSchema: { - 'type': 'object', - 'required': ['symbol'], - 'properties': { - 'symbol': { - 'type': 'string', - 'description': 'Dart symbol to search for, e.g. LxComputed.', - }, - 'maxResults': { - 'type': 'integer', - 'description': 'Maximum number of matches to return.', - 'default': 50, - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final symbol = (args['symbol'] as String?)?.trim() ?? ''; - if (symbol.isEmpty) { - return const ToolCallResult( - message: 'Missing required argument: symbol', - isError: true, - ); - } - - final maxResults = - _readInt(args['maxResults'], fallback: 50).clamp(1, 200); - final packages = _discoverPackages(_workspaceDirectory); - final matches = >[]; - final pattern = RegExp(r'\b' + RegExp.escape(symbol) + r'\b'); - - for (final pkg in packages) { - final libDir = Directory('${pkg.path}/lib'); - if (!libDir.existsSync()) { - continue; - } - - final files = libDir - .listSync(recursive: true, followLinks: false) - .whereType() - .where((file) => file.path.endsWith('.dart')); - - for (final file in files) { - final lines = file.readAsLinesSync(); - for (var i = 0; i < lines.length; i++) { - if (!pattern.hasMatch(lines[i])) { - continue; - } - matches.add({ - 'package': pkg.name, - 'path': _toRelativePath(file.path), - 'line': i + 1, - 'snippet': lines[i].trim(), - }); - if (matches.length >= maxResults) { - break; - } - } - if (matches.length >= maxResults) { - break; - } - } - if (matches.length >= maxResults) { - break; - } - } - - final result = { - 'symbol': symbol, - 'count': matches.length, - 'matches': matches, - }; - return ToolCallResult( - message: jsonEncode(result), structured: result); - }, - ); - - McpTool get _docsSearchTool => McpTool( - name: 'levit_docs_search', - description: - 'Search Levit markdown docs (README/CHANGELOG and docs files) by text query.', - inputSchema: { - 'type': 'object', - 'required': ['query'], - 'properties': { - 'query': { - 'type': 'string', - 'description': 'Case-insensitive substring query.', - }, - 'maxResults': { - 'type': 'integer', - 'default': 20, - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final query = (args['query'] as String?)?.trim() ?? ''; - if (query.isEmpty) { - return const ToolCallResult( - message: 'Missing required argument: query', - isError: true, - ); - } - final maxResults = - _readInt(args['maxResults'], fallback: 20).clamp(1, 100); - final queryLower = query.toLowerCase(); - - final candidates = []; - final rootReadme = File('${_workspaceDirectory.path}/README.md'); - if (rootReadme.existsSync()) { - candidates.add(rootReadme); - } - - for (final pkg in _discoverPackages(_workspaceDirectory)) { - final readme = File('${pkg.path}/README.md'); - if (readme.existsSync()) { - candidates.add(readme); - } - final changelog = File('${pkg.path}/CHANGELOG.md'); - if (changelog.existsSync()) { - candidates.add(changelog); - } - } - - final matches = >[]; - for (final file in candidates) { - final lines = file.readAsLinesSync(); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].toLowerCase().contains(queryLower)) { - continue; - } - matches.add({ - 'path': _toRelativePath(file.path), - 'line': i + 1, - 'snippet': lines[i].trim(), - }); - if (matches.length >= maxResults) { - break; - } - } - if (matches.length >= maxResults) { - break; - } - } - - final result = { - 'query': query, - 'count': matches.length, - 'matches': matches, - }; - return ToolCallResult( - message: jsonEncode(result), structured: result); - }, - ); - - McpTool get _affectedPackagesTool => McpTool( - name: 'levit_affected_packages', - description: - 'Infer affected packages from changed file paths or current git status.', - inputSchema: { - 'type': 'object', - 'properties': { - 'changedPaths': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': - 'Optional list of changed paths. If omitted, git status is used.', - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final explicitPaths = _readStringList(args['changedPaths']); - final changedPaths = explicitPaths ?? await _gitChangedPaths(); - final packages = _packagesFromPaths(changedPaths); - - final result = { - 'changedPathCount': changedPaths.length, - 'changedPaths': changedPaths, - 'count': packages.length, - 'packages': packages, - }; - - return ToolCallResult( - message: jsonEncode(result), structured: result); - }, - ); - - McpTool get _analyzePackagesTool => McpTool( - name: 'levit_analyze_packages', - description: - 'Run dart analyze for selected packages. Defaults to dryRun=true.', - inputSchema: { - 'type': 'object', - 'properties': { - 'packageNames': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': - 'Package names to analyze. Defaults to affected packages.', - }, - 'dryRun': { - 'type': 'boolean', - 'default': true, - }, - }, - 'additionalProperties': false, - }, - handler: (args) async { - final dryRun = args['dryRun'] is bool ? args['dryRun'] as bool : true; - final requested = _readStringList(args['packageNames']) ?? []; - final packages = _discoverPackages(_workspaceDirectory); - final byName = { - for (final pkg in packages) pkg.name: pkg, - }; - - final targetNames = requested.isNotEmpty - ? requested - : await _inferAffectedPackageNames(byName.keys.toSet()); - - if (targetNames.isEmpty) { - final emptyResult = { - 'dryRun': dryRun, - 'count': 0, - 'results': >[], - }; - return ToolCallResult( - message: jsonEncode(emptyResult), - structured: emptyResult, - ); - } - - final runs = >[]; - for (final name in targetNames) { - final pkg = byName[name]; - if (pkg == null) { - runs.add({ - 'package': name, - 'status': 'missing', - 'exitCode': -1, - 'stdout': '', - 'stderr': 'Package not found in workspace scan.', - }); - continue; - } - - if (dryRun) { - runs.add({ - 'package': name, - 'status': 'planned', - 'command': 'dart analyze', - 'path': _toRelativePath(pkg.path), - }); - continue; - } - - final process = await Process.run( - 'dart', - ['analyze'], - workingDirectory: pkg.path, - ); - runs.add({ - 'package': name, - 'status': process.exitCode == 0 ? 'ok' : 'failed', - 'exitCode': process.exitCode, - 'stdout': (process.stdout as String).trim(), - 'stderr': (process.stderr as String).trim(), - }); - } - - final failed = runs.where((run) => run['status'] == 'failed').length; - final result = { - 'dryRun': dryRun, - 'count': runs.length, - 'failed': failed, - 'results': runs, - }; - return ToolCallResult( - message: jsonEncode(result), structured: result); - }, - ); - - Directory _resolveRoot(String? configuredRoot) { - final trimmed = configuredRoot?.trim(); - if (trimmed == null || trimmed.isEmpty) { - return _workspaceDirectory; - } - return Directory(trimmed); - } - - List<_WorkspacePackage> _discoverPackages(Directory root) { - final packagesRoot = Directory('${root.path}/packages'); - if (!packagesRoot.existsSync()) { - return const <_WorkspacePackage>[]; - } - - final results = <_WorkspacePackage>[]; - for (final group in packagesRoot.listSync(followLinks: false)) { - if (group is! Directory) { - continue; - } - for (final packageDir in group.listSync(followLinks: false)) { - if (packageDir is! Directory) { - continue; - } - - final pubspec = File('${packageDir.path}/pubspec.yaml'); - if (!pubspec.existsSync()) { - continue; - } - - final name = _readPackageName(pubspec.readAsStringSync()); - if (name == null) { - continue; - } - - results.add(_WorkspacePackage( - name: name, - path: packageDir.path, - group: _basename(group.path), - )); - } - } - - results.sort((a, b) => a.name.compareTo(b.name)); - return results; - } - - String? _readPackageName(String pubspecContent) { - final match = RegExp(r'^name:\s*([^\s]+)', multiLine: true) - .firstMatch(pubspecContent); - return match?.group(1); - } - - String _basename(String path) { - final normalized = path.replaceAll('\\', '/'); - final index = normalized.lastIndexOf('/'); - if (index < 0) { - return normalized; - } - return normalized.substring(index + 1); - } - - String _toRelativePath(String path) { - final root = _workspaceDirectory.path.replaceAll('\\', '/'); - final normalized = path.replaceAll('\\', '/'); - if (normalized.startsWith('$root/')) { - return normalized.substring(root.length + 1); - } - return normalized; - } - - List? _readStringList(Object? value) { - if (value is! List) { - return null; - } - return value - .whereType() - .map((entry) => entry.trim()) - .where((entry) => entry.isNotEmpty) - .toList(growable: false); - } - - Future> _gitChangedPaths() async { - final result = await Process.run( - 'git', - ['status', '--porcelain'], - workingDirectory: _workspaceDirectory.path, - ); - if (result.exitCode != 0) { - return const []; - } - - final lines = (result.stdout as String) - .split('\n') - .map((line) => line.trimRight()) - .where((line) => line.isNotEmpty) - .toList(growable: false); - - final paths = []; - for (final line in lines) { - if (line.length < 4) { - continue; - } - final pathPart = line.substring(3).trim(); - if (pathPart.isEmpty) { - continue; - } - final renamed = pathPart.split(' -> '); - paths.add(renamed.last.trim()); - } - - return paths; - } - - List _packagesFromPaths(List changedPaths) { - final packageRoots = {}; - - for (final rawPath in changedPaths) { - final path = rawPath.replaceAll('\\', '/'); - final parts = path.split('/'); - if (parts.length < 3) { - continue; - } - if (parts[0] != 'packages') { - continue; - } - packageRoots.add('packages/${parts[1]}/${parts[2]}'); - } - - final sorted = packageRoots.toList(growable: false)..sort(); - return sorted; - } - - Future> _inferAffectedPackageNames( - Set validNames) async { - final changedPaths = await _gitChangedPaths(); - final affectedPaths = _packagesFromPaths(changedPaths).toSet(); - final names = []; - - for (final pkg in _discoverPackages(_workspaceDirectory)) { - final relative = _toRelativePath(pkg.path); - if (affectedPaths.contains(relative) && validNames.contains(pkg.name)) { - names.add(pkg.name); - } - } - - return names; - } -} - -class _WorkspacePackage { - const _WorkspacePackage({ - required this.name, - required this.path, - required this.group, - }); - - final String name; - final String path; - final String group; - - Map toJson() { - return { - 'name': name, - 'path': path, - 'group': group, - }; - } -} - -int _readInt(Object? raw, {required int fallback}) { - if (raw is int) { - return raw; - } - if (raw is num) { - return raw.toInt(); - } - return fallback; -} - -List _readIntList(Object? raw, {required List fallback}) { - if (raw is! List) { - return fallback; - } - return raw - .whereType() - .map((value) => value.toInt()) - .toList(growable: false); -} diff --git a/tooling/levit_mcp_server/pubspec.yaml b/tooling/levit_mcp_server/pubspec.yaml deleted file mode 100644 index 19b6b78..0000000 --- a/tooling/levit_mcp_server/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: levit_mcp_server -description: MCP server exposing Levit-focused tooling over stdio. -version: 0.1.0 -publish_to: none -repository: https://github.com/softilab/levit - -environment: - sdk: ^3.0.0 - -dependencies: - levit_reactive: - path: ../../packages/core/levit_reactive - -dev_dependencies: - lints: ^6.0.0 - test: ^1.25.6 diff --git a/tooling/levit_mcp_server/pubspec_overrides.yaml b/tooling/levit_mcp_server/pubspec_overrides.yaml deleted file mode 100644 index 901fcb9..0000000 --- a/tooling/levit_mcp_server/pubspec_overrides.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# melos_managed_dependency_overrides: levit_reactive -dependency_overrides: - levit_reactive: - path: ../../packages/core/levit_reactive diff --git a/tooling/levit_mcp_server/test/server_test.dart b/tooling/levit_mcp_server/test/server_test.dart deleted file mode 100644 index 26a850d..0000000 --- a/tooling/levit_mcp_server/test/server_test.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:levit_mcp_server/levit_mcp_server.dart'; -import 'package:test/test.dart'; - -void main() { - test('server supports resources/list and resources/read', () async { - final workspace = await _createWorkspaceFixture(); - addTearDown(() async { - if (workspace.existsSync()) { - await workspace.delete(recursive: true); - } - }); - - final registry = LevitToolRegistry(workspaceDirectory: workspace); - final server = LevitMcpServer(registry: registry); - - final input = StreamController>(); - final outputController = StreamController>(); - final bytes = BytesBuilder(); - final collectOutput = outputController.stream.listen(bytes.add); - final output = IOSink(outputController.sink); - - final serving = server.serve(input.stream, output); - - _writeRequest(input, { - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'resources/list', - 'params': {}, - }); - - _writeRequest(input, { - 'jsonrpc': '2.0', - 'id': 2, - 'method': 'resources/read', - 'params': { - 'uri': 'levit://workspace/packages', - }, - }); - - await input.close(); - await serving; - await output.close(); - await collectOutput.cancel(); - await outputController.close(); - - final responses = _parseResponses(bytes.takeBytes()); - expect(responses.length, 2); - expect( - responses.first['result']['resources'][0]['uri'], - 'levit://workspace/packages', - ); - - final contents = responses.last['result']['contents'] as List; - expect(contents.single['uri'], 'levit://workspace/packages'); - }); -} - -Future _createWorkspaceFixture() async { - final root = - await Directory.systemTemp.createTemp('levit_mcp_server_fixture_'); - final pubspec = File('${root.path}/packages/core/alpha_core/pubspec.yaml'); - await pubspec.parent.create(recursive: true); - await pubspec.writeAsString('name: alpha_core\n'); - return root; -} - -void _writeRequest( - StreamController> input, - Map payload, -) { - final body = utf8.encode(jsonEncode(payload)); - final header = ascii.encode('Content-Length: ${body.length}\r\n\r\n'); - input.add([...header, ...body]); -} - -List> _parseResponses(List raw) { - final responses = >[]; - var offset = 0; - - while (offset < raw.length) { - final headerEnd = _indexOfSequence(raw, [13, 10, 13, 10], offset); - if (headerEnd < 0) { - break; - } - final header = ascii.decode(raw.sublist(offset, headerEnd)); - final length = _contentLength(header); - final bodyStart = headerEnd + 4; - final bodyEnd = bodyStart + length; - final payload = utf8.decode(raw.sublist(bodyStart, bodyEnd)); - responses.add(jsonDecode(payload) as Map); - offset = bodyEnd; - } - - return responses; -} - -int _indexOfSequence(List data, List target, int start) { - for (var i = start; i <= data.length - target.length; i++) { - var ok = true; - for (var j = 0; j < target.length; j++) { - if (data[i + j] != target[j]) { - ok = false; - break; - } - } - if (ok) { - return i; - } - } - return -1; -} - -int _contentLength(String header) { - final line = header - .split('\r\n') - .firstWhere((entry) => entry.toLowerCase().startsWith('content-length:')); - return int.parse(line.split(':').last.trim()); -} diff --git a/tooling/levit_mcp_server/test/tools_test.dart b/tooling/levit_mcp_server/test/tools_test.dart deleted file mode 100644 index d9a161a..0000000 --- a/tooling/levit_mcp_server/test/tools_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:levit_mcp_server/levit_mcp_server.dart'; -import 'package:test/test.dart'; - -void main() { - group('LevitToolRegistry', () { - late Directory workspace; - late LevitToolRegistry registry; - - setUp(() async { - workspace = await _createWorkspaceFixture(); - registry = LevitToolRegistry(workspaceDirectory: workspace); - }); - - tearDown(() async { - if (workspace.existsSync()) { - await workspace.delete(recursive: true); - } - }); - - test('levit_reactive_simulate returns deterministic output', () async { - final result = - await registry.call('levit_reactive_simulate', { - 'initial': 2, - 'updates': [3, -1], - }); - - expect(result.isError, isFalse); - expect(result.structured, isNotNull); - expect(result.structured!['finalCount'], 4); - expect(result.structured!['finalDoubled'], 8); - }); - - test('levit_workspace_scan finds local packages', () async { - final result = - await registry.call('levit_workspace_scan', {}); - - expect(result.isError, isFalse); - expect(result.structured!['count'], 2); - - final packages = (result.structured!['packages'] as List) - .cast>(); - expect(packages.any((pkg) => pkg['name'] == 'alpha_core'), isTrue); - expect(packages.any((pkg) => pkg['name'] == 'alpha_kit'), isTrue); - }); - - test('levit_api_lookup returns symbol matches', () async { - final result = await registry.call('levit_api_lookup', { - 'symbol': 'AlphaController', - }); - - expect(result.isError, isFalse); - expect(result.structured!['count'], 1); - }); - - test('levit_docs_search returns markdown hits', () async { - final result = await registry.call('levit_docs_search', { - 'query': 'reactive signals', - }); - - expect(result.isError, isFalse); - expect(result.structured!['count'], 1); - }); - - test('levit_affected_packages maps changed paths', () async { - final result = - await registry.call('levit_affected_packages', { - 'changedPaths': [ - 'packages/core/alpha_core/lib/alpha_core.dart', - 'README.md', - ], - }); - - expect(result.isError, isFalse); - expect(result.structured!['count'], 1); - expect( - result.structured!['packages'], ['packages/core/alpha_core']); - }); - - test('levit_analyze_packages dry run plans command', () async { - final result = - await registry.call('levit_analyze_packages', { - 'packageNames': ['alpha_core'], - }); - - expect(result.isError, isFalse); - expect(result.structured!['dryRun'], isTrue); - final runs = (result.structured!['results'] as List) - .cast>(); - expect(runs.single['status'], 'planned'); - }); - - test('resource list and read are available', () async { - final resources = registry.listResources(); - expect( - resources.any((r) => r.uri == 'levit://workspace/packages'), isTrue); - - final read = await registry.readResource('levit://workspace/packages'); - final contents = - (read['contents'] as List).cast>(); - expect(contents.single['mimeType'], 'application/json'); - - final jsonBody = - jsonDecode(contents.single['text'] as String) as Map; - expect(jsonBody['count'], 2); - }); - }); -} - -Future _createWorkspaceFixture() async { - final root = await Directory.systemTemp.createTemp('levit_mcp_fixture_'); - - await _writeFile('${root.path}/README.md', '# Workspace\n'); - - await _writeFile( - '${root.path}/packages/core/alpha_core/pubspec.yaml', - 'name: alpha_core\n', - ); - await _writeFile( - '${root.path}/packages/core/alpha_core/lib/alpha_core.dart', - 'class AlphaController {}\n', - ); - await _writeFile( - '${root.path}/packages/core/alpha_core/README.md', - 'alpha_core uses reactive signals.\n', - ); - - await _writeFile( - '${root.path}/packages/kits/alpha_kit/pubspec.yaml', - 'name: alpha_kit\n', - ); - await _writeFile( - '${root.path}/packages/kits/alpha_kit/lib/alpha_kit.dart', - 'export "package:alpha_core/alpha_core.dart";\n', - ); - await _writeFile( - '${root.path}/packages/kits/alpha_kit/README.md', - 'kit readme\n', - ); - - return root; -} - -Future _writeFile(String path, String content) async { - final file = File(path); - await file.parent.create(recursive: true); - await file.writeAsString(content); -}