arsync_lints

A powerful lint package for Flutter/Dart that enforces the Arsync 4-Layer Architecture with strict separation of concerns, Riverpod best practices, and clean code standards.

Dart Flutter License

Requirements

  • Dart SDK: 3.10.0 or higher
  • Flutter SDK: 3.38.0 or higher

This package uses the native analysis_server_plugin system introduced in Dart 3.10, which provides better IDE integration and faster analysis compared to the legacy custom_lint approach.

Overview

arsync_lints treats architectural violations as build errors, not warnings. This ensures your codebase maintains clean architecture from day one and prevents "spaghetti code" from creeping into your project.

Installation

1. Add to your pubspec.yaml

dev_dependencies:
  arsync_lints: ^1.0.0

2. Enable the plugin in analysis_options.yaml

# Dart 3.10+ native plugin system
plugins:
  arsync_lints:

analyzer:
  exclude:
    - '**/*.g.dart'
    - '**/*.freezed.dart'

Note: The plugins: section is a top-level key, not nested under analyzer:.

3. Restart your IDE

After adding the plugin, restart your IDE (VS Code, Android Studio, IntelliJ) to activate the lints. The diagnostics will appear automatically in your editor.

4. Run analysis

# Analyze your project
dart analyze

Rules Reference

Category A: Architectural Layer Isolation

These rules prevent layers from "leaking" into each other.

Rule Target Description
presentation_layer_isolation screens/, widgets/ Cannot import repositories, cloud_firestore, http, or dio. Use Dart records instead of parameter classes.
shared_widget_purity widgets/ Cannot import providers or Riverpod packages. Each file must have only ONE public widget.
model_purity models/ Must use @freezed and have fromJson factory; no provider imports
repository_isolation repositories/ Cannot import screens or UI-specific Riverpod (flutter_riverpod, hooks_riverpod)

Example: presentation_layer_isolation

// BAD - lib/screens/home_screen.dart
import 'package:my_app/repositories/auth_repository.dart'; // ERROR!
import 'package:dio/dio.dart'; // ERROR!

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final repo = AuthRepository(); // Direct repo access!
    return Container();
  }
}

// GOOD - lib/screens/home_screen.dart
import 'package:my_app/providers/auth_provider.dart';

class HomeScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final authState = ref.watch(authProvider); // Watch provider instead
    return Container();
  }
}

Example: shared_widget_purity (Single Widget Per File)

// BAD - lib/widgets/buttons.dart
class PrimaryButton extends StatelessWidget {} // OK - first public widget
class SecondaryButton extends StatelessWidget {} // ERROR! Multiple public widgets

// GOOD - lib/widgets/primary_button.dart
class PrimaryButton extends StatelessWidget {}
class _ButtonContent extends StatelessWidget {} // OK - private helper

Example: Use Records Instead of Parameter Classes

// BAD - lib/screens/profile_screen.dart
class UpdateProfileParams {
  final String userId;
  final String name;
  const UpdateProfileParams({required this.userId, required this.name});
}

// GOOD - Use Dart records
typedef UpdateProfileParams = ({
  String userId,
  String name,
  String? phone,
});

// Usage
void updateProfile(UpdateProfileParams params) {
  print(params.userId);
}

Category B: Riverpod & State Management

These rules enforce the "Arsync Riverpod Pattern".

Rule Target Description
provider_autodispose_enforcement providers/ Providers must use .autoDispose or call ref.keepAlive().
provider_file_naming providers/ Files must end with _provider.dart and contain a matching Notifier class
provider_state_class providers/ State classes must be @freezed and defined in the same file
provider_declaration_syntax providers/ Must use .new constructor syntax (e.g., AuthNotifier.new)
provider_class_restriction providers/ Only Notifier classes and @freezed state classes allowed
provider_single_per_file providers/ Each file can only have ONE NotifierProvider matching the file name
viewmodel_naming_convention providers/ Notifier classes must end with "Notifier"
no_context_in_providers providers/ BuildContext cannot be used as a parameter
async_viewmodel_safety providers/ Async methods in Notifiers must have try/catch

Example: provider_file_naming

// File: lib/providers/auth_provider.dart

// GOOD
class AuthNotifier extends Notifier<AuthState> { ... } // Matches file name prefix

// BAD - lib/providers/auth.dart (missing _provider suffix)
// BAD - lib/providers/auth_provider.dart with class UserNotifier (prefix mismatch)

Example: provider_declaration_syntax

// BAD - Explicit generics and closure
final authProvider = NotifierProvider.autoDispose<AuthNotifier, AuthState>(
  () => AuthNotifier(),
);

// GOOD - Clean .new syntax
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);

Example: provider_autodispose_enforcement

// BAD - Memory leak potential
final authProvider = NotifierProvider<AuthNotifier, AuthState>(() {
  return AuthNotifier();
}); // ERROR: Missing autoDispose

// GOOD - Option 1: Use autoDispose
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);

// GOOD - Option 2: Use keepAlive for persistent state
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);

class AuthNotifier extends Notifier<AuthState> {
  @override
  AuthState build() {
    ref.keepAlive(); // Explicitly opt-in to persistence
    return AuthState();
  }
}

Example: provider_state_class

// BAD - State class not @freezed
class AuthState {
  final bool isLoggedIn;
  AuthState(this.isLoggedIn);
}

// GOOD - Immutable state with @freezed
@freezed
class AuthState with _$AuthState {
  const factory AuthState({
    @Default(false) bool isLoggedIn,
    User? user,
  }) = _AuthState;
}

Example: provider_single_per_file

// BAD - lib/providers/auth_provider.dart
final authProvider = NotifierProvider.autoDispose(AuthNotifier.new);
final userProvider = NotifierProvider.autoDispose(UserNotifier.new); // ERROR!

// GOOD - One provider per file
// lib/providers/auth_provider.dart -> authProvider
// lib/providers/user_provider.dart -> userProvider

Example: async_viewmodel_safety

// BAD - Unhandled async errors
class UserNotifier extends AsyncNotifier<User> {
  Future<void> fetchUser() async {
    await userRepository.getUser(); // ERROR: No try/catch
  }
}

// GOOD - Proper error handling
class UserNotifier extends AsyncNotifier<User> {
  Future<void> fetchUser() async {
    try {
      await userRepository.getUser();
    } catch (e) {
      ref.showExceptionSheet(e);
    }
  }
}

Category C: Repository & Data Integrity

Rule Target Description
repository_provider_declaration repositories/ Must define a Provider ending with RepoProvider
repository_dependency_injection repositories/ Dependencies must be injected via constructor; Ref parameter banned
repository_class_restriction repositories/ Only classes with "Repository" in name; files must end with _repository.dart
repository_no_try_catch repositories/ Repositories must throw errors, not catch them
repository_async_return repositories/ Public methods must return Future<T> or Stream<T>

Example: repository_provider_declaration

// lib/repositories/auth_repository.dart

// GOOD - Provider at top ending with RepoProvider
final authRepoProvider = Provider<AuthRepository>((ref) {
  final dio = ref.watch(dioProvider);
  return AuthRepository(dio);
});

class AuthRepository {
  final Dio _dio;
  AuthRepository(this._dio);
}

Example: repository_dependency_injection

// BAD - Direct instantiation
class AuthRepository {
  final Dio _dio = Dio(); // ERROR: Create objects directly
}

// BAD - Ref parameter
class AuthRepository {
  final Ref ref; // ERROR: Ref not allowed
  AuthRepository(this.ref);
}

// GOOD - Constructor injection
class AuthRepository {
  final Dio _dio;
  AuthRepository(this._dio); // Injected via provider
}

Example: repository_no_try_catch

// BAD - Swallowing errors
class UserRepository {
  Future<User?> getUser(String id) async {
    try {
      return await api.fetchUser(id);
    } catch (e) {
      return null; // ERROR: Hiding the error!
    }
  }
}

// GOOD - Let errors bubble up
class UserRepository {
  Future<User> getUser(String id) async {
    return await api.fetchUser(id); // Throws on error
  }
}

Category D: Code Quality & Complexity

Rule Description
complexity_limits Max 4 parameters, max 3 nesting levels, max 60 lines per method, max 120 lines in build(), no nested ternary
global_variable_restriction Top-level variables must be private (_), constants (k prefix), or Providers. Top-level functions must be private, k-prefixed (in constants.dart), or main()
print_ban print() and debugPrint() are banned; use custom logging using log() extension method on Object
barrel_file_restriction No index.dart barrel files in screens/, features/, widgets/, or providers/
ignore_file_ban // ignore_for_file: comments are banned

Example: complexity_limits

// BAD - Nested ternary
Widget build(BuildContext context) {
  return isLoading
    ? LoadingWidget()
    : hasError
      ? ErrorWidget()  // ERROR: Nested ternary!
      : ContentWidget();
}

// GOOD - Use if/else or switch
Widget build(BuildContext context) {
  if (isLoading) return LoadingWidget();
  if (hasError) return ErrorWidget();
  return ContentWidget();
}

// BAD - Too many parameters (max 4)
void updateUser(
  String id,
  String name,
  String email,
  String phone,
  String address, // ERROR: More than 4 parameters
) {}

// GOOD - Use a parameter object
void updateUser(UpdateUserParams params) {}

// BAD - Method exceeds 60 lines
void processData() {
  // ... 61+ lines of code ... // ERROR!
}

// GOOD - Extract into smaller methods
void processData() {
  _validateInput();
  _transformData();
  _saveResult();
}

Example: global_variable_restriction

// BAD - lib/utils/helpers.dart
String globalApiUrl = 'https://api.example.com'; // ERROR!
void helperFunction() {} // ERROR: Top-level function

// GOOD - lib/utils/constants.dart
const kApiUrl = 'https://api.example.com'; // OK: k prefix in constants.dart
void kFormatDate() {} // OK: k prefix in constants.dart

// GOOD - lib/providers/config_provider.dart
final configProvider = Provider((ref) => Config()); // OK: Provider variable

// GOOD - Private functions anywhere
void _internalHelper() {} // OK: Private function

Category E: UI Safety & Consistency

Rule Target Description
hook_safety_enforcement build() methods Controllers must use hooks; GlobalKey<FormState>() banned in HookWidgets
scaffold_location widgets/ Scaffold is not allowed in widgets folder
asset_safety All files Image.asset() must use constants, not string literals
file_class_match All files Class name must match file name (snake_case to PascalCase)

Example: hook_safety_enforcement

// BAD - Memory leak in build
class MyWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController(); // ERROR: Leaks memory!
    return TextField(controller: controller);
  }
}

// GOOD - Use hooks
class MyWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = useTextEditingController();
    return TextField(controller: controller);
  }
}

// BAD - GlobalKey<FormState> resets on keyboard open/orientation change
class MyFormWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formKey = GlobalKey<FormState>(); // ERROR: Resets unexpectedly!
    return Form(key: formKey, child: ...);
  }
}

// GOOD - Use GlobalObjectKey with context for stable identity
class MyFormWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formKey = GlobalObjectKey<FormState>(context); // Stable across rebuilds
    return Form(key: formKey, child: ...);
  }
}

Example: asset_safety

// BAD - Typo-prone string literal
Image.asset('assets/images/logo.png'); // ERROR!

// GOOD - Use constants
// lib/utils/images.dart
class Images {
  static const logo = 'assets/images/logo.png';
}

// Usage
Image.asset(Images.logo);

Example: file_class_match

// File: lib/screens/user_profile_screen.dart

// BAD
class ProfilePage {} // ERROR: Should be UserProfileScreen

// GOOD
class UserProfileScreen {} // Matches file name

Complete Rules List

# Rule Name Category Target
1 presentation_layer_isolation Architecture screens/, widgets/
2 shared_widget_purity Architecture widgets/
3 model_purity Architecture models/
4 repository_isolation Architecture repositories/
5 provider_autodispose_enforcement Riverpod providers/
6 provider_file_naming Riverpod providers/
7 provider_state_class Riverpod providers/
8 provider_declaration_syntax Riverpod providers/
9 provider_class_restriction Riverpod providers/
10 provider_single_per_file Riverpod providers/
11 viewmodel_naming_convention Riverpod providers/
12 no_context_in_providers Riverpod providers/
13 async_viewmodel_safety Riverpod providers/
14 repository_provider_declaration Repository repositories/
15 repository_dependency_injection Repository repositories/
16 repository_class_restriction Repository repositories/
17 repository_no_try_catch Repository repositories/
18 repository_async_return Repository repositories/
19 complexity_limits Code Quality lib/
20 global_variable_restriction Code Quality lib/
21 print_ban Code Quality lib/
22 barrel_file_restriction Code Quality lib/
23 ignore_file_ban Code Quality lib/
24 hook_safety_enforcement UI Safety build() methods
25 scaffold_location UI Safety widgets/
26 asset_safety UI Safety All files
27 file_class_match UI Safety All files

Project Structure

For arsync_lints to work correctly, organize your project like this:

lib/
├── main.dart
├── screens/              # UI pages (can use Scaffold)
│   ├── home/
│   │   └── home_screen.dart
│   └── auth/
│       └── login_screen.dart
├── widgets/              # Reusable UI components (no Scaffold, no providers)
│   ├── buttons/
│   │   └── primary_button.dart
│   └── cards/
│       └── user_card.dart
├── providers/            # State management (Riverpod Notifiers)
│   ├── core/             # Infrastructure providers (dioProvider, etc.)
│   │   └── dio_provider.dart
│   ├── auth_provider.dart
│   └── user_provider.dart
├── models/               # Data classes (Freezed)
│   ├── user.dart
│   └── auth_state.dart
├── repositories/         # Data access layer
│   ├── auth_repository.dart
│   └── user_repository.dart
└── utils/
    ├── constants.dart    # k-prefixed constants and functions
    └── images.dart       # Asset path constants

Suppressing Rules

While // ignore_for_file: is banned, you can still use line-specific ignores for rare exceptions:

// ignore: print_ban
print('Debug only - remove before commit');

CI/CD Integration

Add to your CI pipeline to enforce architecture:

# GitHub Actions example
- name: Run Analysis
  run: dart analyze --fatal-infos --fatal-warnings

Philosophy

"Architecture is about intent. These rules make your intent explicit and your boundaries clear."

The Arsync architecture is designed to:

  1. Prevent spaghetti code - Clear boundaries between layers
  2. Enable testability - Each layer can be tested in isolation
  3. Improve maintainability - New developers understand the structure immediately
  4. Catch issues early - Violations are build errors, not runtime surprises

Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

License

MIT License - see LICENSE for details.

Libraries

main
arsync_lints - A lint package for Flutter/Dart that enforces the Arsync 4-layer architecture with strict separation of concerns, Riverpod best practices, and clean code standards.