arsync_lints 0.0.9
arsync_lints: ^0.0.9 copied to clipboard
A custom lint package for Flutter/Dart that enforces the Arsync 4-layer architecture with strict separation of concerns, Riverpod best practices, and clean code standards.
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.
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:
- Prevent spaghetti code - Clear boundaries between layers
- Enable testability - Each layer can be tested in isolation
- Improve maintainability - New developers understand the structure immediately
- 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.