Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .opencode/module-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
lib/
model/ ← data classes, enums, exceptions (zero internal deps)
data/ ← data access, serialization (depends on model/)
data/ ← data access, serialization, file I/O (depends on model/)
analyzer/ ← analysis engine, rule checkers (depends on model/, data/)
checker/ ← individual rule implementations (depends on model/)
util/ ← shared utilities (zero internal deps)
Expand Down Expand Up @@ -53,12 +53,24 @@ bin/
| Layer | Role |
|---|---|
| `model/` | Data classes, enums, custom exceptions. Zero knowledge of other layers. |
| `data/` | Config loading, JSON parsing, file I/O. Depends on `model/` for types. |
| `data/` | Config loading, JSON mapping, Dart source parsing, file I/O. Depends on `model/` for types and may use external packages (`yaml`, `analyzer`). |
| `analyzer/` | Core logic: walks files, resolves imports, checks rules. Orchestrates the analysis. |
| `analyzer/checker/` | Individual rule implementations. Each checker validates one rule type. |
| `util/` | Shared helpers (string, file, path utilities). No knowledge of project domain. |
| `bin/main.dart` | CLI entry point. Only calls the public API — never internal modules directly. |

## Data Layer Contents

- `config_item_mapper.dart` — maps JSON objects to `EagleEyeConfigItem`.
- `config_keys.dart` — JSON key names (wire format).
- `dart_source_loader.dart` — reads Dart files and returns `CompilationUnit`.
- `json_converter.dart` — parses the configuration array.
- `eagle_eye_repository.dart` — exposes config, application name, and Dart paths.
- `file_helper.dart` — internal file-system operations.

The model layer has no wire-format knowledge: `EagleEyeConfigItem.fromJson`
does not exist, and JSON keys live in `data/`.

## Adding a New Feature

1. If a new domain type is needed → add to `model/`
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- Internal refactor: move JSON mapping, configuration keys, and Dart source loading into `lib/data/`. `EagleEyeConfigItem` is now a pure model with no `fromJson` factory, and `EagleEyeRepository` returns Dart file paths instead of `File` objects. `FileHelper` no longer references `util/logger_helper.dart`.
- Add a configuration validation step before analysis. EagleEye now rejects malformed rules and configurations where more than one rule matches the same file, instead of silently applying the first match.
- Add a `--verbose` (`-v`) flag that prints every configuration, file, import, and pattern check performed by EagleEye. Add `--help` (`-h`) for usage information.

Expand Down
78 changes: 78 additions & 0 deletions lib/data/config_item_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'package:eagle_eye/data/config_keys.dart';
import 'package:eagle_eye/model/eagle_eye_config_item.dart';
import 'package:eagle_eye/model/exceptions/invalid_config_exception.dart';

/// Maps configuration JSON objects into [EagleEyeConfigItem] instances.
///
/// This is a data-layer concern: the model stays free of wire-format
/// knowledge, while the mapper owns key names and type validation.
class ConfigItemMapper {
/// Maps a single JSON object into an [EagleEyeConfigItem].
///
/// The [ConfigKeys.nameEagleItemKey] field is optional and maps to `null`
/// when absent. Throws an [InvalidConfigException] when a required field is
/// missing or when a field has an unexpected type.
EagleEyeConfigItem map(Map<String, dynamic> json) {
final dependenciesAllowed = _readDependenciesAllowed(json);
final forbiddenDependencies = _readStringList(
json,
ConfigKeys.forbiddenDependenciesEagleItemKey,
);
final exclusiveDependencies = _readStringList(
json,
ConfigKeys.exclusiveDependenciesEagleItemKey,
);
final filePattern = _readFilePattern(json);
final name = _readName(json);

return EagleEyeConfigItem(
dependenciesAllowed: dependenciesAllowed,
forbiddenDependencies: forbiddenDependencies,
exclusiveDependencies: exclusiveDependencies,
filePattern: filePattern,
name: name,
);
}

bool? _readDependenciesAllowed(Map<String, dynamic> json) {
final key = ConfigKeys.dependenciesAllowedEagleItemKey;
final value = json[key];
if (value != null && value is! bool) {
throw InvalidConfigException('The "$key" value must be a boolean.');
}
return value as bool?;
}

List<String>? _readStringList(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) {
return null;
}
if (value is! List || value.any((item) => item is! String)) {
throw InvalidConfigException(
'The "$key" value must be a list of strings.',
);
}
return List<String>.from(value);
}

String _readFilePattern(Map<String, dynamic> json) {
final key = ConfigKeys.filePatternEagleItemKey;
final value = json[key];
if (value is! String || value.trim().isEmpty) {
throw InvalidConfigException(
'The "$key" value is required and must be a non-empty string.',
);
}
return value;
}

String? _readName(Map<String, dynamic> json) {
final key = ConfigKeys.nameEagleItemKey;
final value = json[key];
if (value != null && value is! String) {
throw InvalidConfigException('The "$key" value must be a string.');
}
return value as String?;
}
}
File renamed without changes.
17 changes: 17 additions & 0 deletions lib/data/dart_source_loader.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';

/// Loads Dart source files from disk and parses them into ASTs.
///
/// This is a data-layer concern: it owns reading source files and turning
/// them into [CompilationUnit] values for the analyzer.
class DartSourceLoader {
/// Parses the Dart file at [path] into a [CompilationUnit].
CompilationUnit load(String path) {
return parseFile(
path: path,
featureSet: FeatureSet.latestLanguageVersion(),
).unit;
}
}
19 changes: 8 additions & 11 deletions lib/data/eagle_eye_repository.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import 'dart:io';

import 'package:eagle_eye/data/file_helper.dart';
import 'package:eagle_eye/data/json_converter.dart';
import 'package:eagle_eye/model/eagle_eye_config.dart';
Expand All @@ -21,8 +19,9 @@ abstract class EagleEyeRepository {
/// Returns an [EagleEyeConfig]
Future<EagleEyeConfig> getAndCheckIfConfigFileExists();

/// Returns all Dart files found within the project’s `lib/` directory.
List<File> allDartFiles();
/// Returns the paths of all Dart files found within the project’s `lib/`
/// directory.
List<String> allDartPaths();

/// Returns the application’s name as defined in the project structure.
String getApplicationName();
Expand Down Expand Up @@ -56,17 +55,15 @@ class EagleEyeRepositoryImpl extends EagleEyeRepository {
});

@override
List<File> allDartFiles() {
Directory libsDir = fileHelper.getAndCheckIfLibsDirectoryExists(
libsFolderName,
);
return fileHelper.allDartFiles(libsDir);
List<String> allDartPaths() {
final libsDir = fileHelper.getAndCheckIfLibsDirectoryExists(libsFolderName);
return fileHelper.allDartFiles(libsDir).map((file) => file.path).toList();
}

@override
Future<EagleEyeConfig> getAndCheckIfConfigFileExists() async {
final String configFileContent =
await fileHelper.getAndCheckIfConfigFileExists(configFileName);
final String configFileContent = await fileHelper
.getAndCheckIfConfigFileExists(configFileName);

return jsonConverter.convert(configFileContent);
}
Expand Down
9 changes: 3 additions & 6 deletions lib/data/file_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'package:eagle_eye/model/exceptions/application_name_not_found_exception.
import 'package:eagle_eye/model/exceptions/config_file_not_found_exception.dart';
import 'package:eagle_eye/model/exceptions/empty_dart_file_list_exception.dart';
import 'package:eagle_eye/model/exceptions/library_folder_not_found_exception.dart';
import 'package:eagle_eye/util/logger_helper.dart';
import 'package:yaml/yaml.dart';

/// Provides utility methods for interacting with project files and directories.
Expand All @@ -17,10 +16,9 @@ import 'package:yaml/yaml.dart';
class FileHelper {
// Checks if the specified configuration file exists and reads its contents.
///
/// If the file does not exist, logs an error message via [LoggerHelper].
/// Throws a [ConfigFileNotFoundException] if the file does not exist.
///
/// Returns the file content as a [String].
/// Throws a [ConfigFileNotFoundException] if the file is not specified
Future<String> getAndCheckIfConfigFileExists(String configFile) async {
final file = File(configFile);
if (!file.existsSync()) {
Expand All @@ -32,11 +30,10 @@ class FileHelper {

/// Ensures the specified [libsFolderName] directory exists and returns it.
///
/// If the directory does not exist, logs an error message via [LoggerHelper].
/// Throws a [LibraryFolderNotFoundException] if the directory does not
/// exist.
///
/// Returns the [Directory] representing the `lib/` folder.
/// Throws a [LibraryFolderNotFoundException] if the lib folder is not
/// available.
Directory getAndCheckIfLibsDirectoryExists(String libsFolderName) {
final projectDirectory = Directory(libsFolderName);

Expand Down
14 changes: 10 additions & 4 deletions lib/data/json_converter.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';

import 'package:eagle_eye/model/config_keys.dart';
import 'package:eagle_eye/data/config_item_mapper.dart';
import 'package:eagle_eye/data/config_keys.dart';
import 'package:eagle_eye/model/eagle_eye_config.dart';
import 'package:eagle_eye/model/eagle_eye_config_item.dart';
import 'package:eagle_eye/model/exceptions/invalid_config_exception.dart';
Expand All @@ -20,6 +21,13 @@ import 'package:eagle_eye/model/exceptions/invalid_json_key_exception.dart';
/// - `filePattern` — defines which files the rule applies to
/// - `name` — optional human-readable rule name
class JsonConverter {
/// Creates a new [JsonConverter].
JsonConverter({required ConfigItemMapper mapper}) {
_mapper = mapper;
}

late ConfigItemMapper _mapper;

void _checkKeys(List<String> keys) {
final validKeys = [
ConfigKeys.filePatternEagleItemKey,
Expand Down Expand Up @@ -68,9 +76,7 @@ class JsonConverter {
_checkKeys(jsonDataItem.keys.toList());

try {
EagleEyeConfigItem configItem = EagleEyeConfigItem.fromJson(
jsonDataItem,
);
EagleEyeConfigItem configItem = _mapper.map(jsonDataItem);
eagleConfigItems.add(configItem);
} on InvalidConfigException catch (e) {
throw InvalidConfigException('Rule #${index + 1}: ${e.details}');
Expand Down
21 changes: 9 additions & 12 deletions lib/eagle_eye_launcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class EagleEyeLauncher {
EagleEyeConfig configFile = await _locator.repository
.getAndCheckIfConfigFileExists();

List<File> dartFiles = _locator.repository.allDartFiles();
List<String> dartPaths = _locator.repository.allDartPaths();

String applicationName = _locator.repository.getApplicationName();

Expand All @@ -77,14 +77,11 @@ class EagleEyeLauncher {
'${EagleEyeRepositoryImpl.configFileName}',
);
onCheck?.call('Application name: $applicationName');
onCheck?.call('Found ${dartFiles.length} Dart file(s) in lib/');
onCheck?.call('Found ${dartPaths.length} Dart file(s) in lib/');

_locator
.createConfigValidator(onCheck: onCheck)
.validate(
config: configFile,
filePaths: dartFiles.map((file) => file.path),
);
.validate(config: configFile, filePaths: dartPaths);

final matcher = _locator.createMatcher(
config: configFile,
Expand All @@ -93,15 +90,15 @@ class EagleEyeLauncher {

List<Violation> errors = [];

for (final file in dartFiles) {
onCheck?.call('Checking ${file.path}');
for (final path in dartPaths) {
onCheck?.call('Checking $path');

final unit = _locator.parseDartFile(file.path);
EagleEyeConfigItem? eagleEyeItem = matcher.find(file.path);
final unit = _locator.dartSourceLoader.load(path);
EagleEyeConfigItem? eagleEyeItem = matcher.find(path);
if (eagleEyeItem != null) {
final visitor = _locator.createVisitor(
configItem: eagleEyeItem,
filePath: file.path,
filePath: path,
applicationName: applicationName,
unit: unit,
errorCallback: (error) => errors.add(error),
Expand All @@ -114,7 +111,7 @@ class EagleEyeLauncher {
}

onCheck?.call(
'Checked ${dartFiles.length} file(s); '
'Checked ${dartPaths.length} file(s); '
'${errors.length} violation(s) found',
);

Expand Down
71 changes: 0 additions & 71 deletions lib/model/eagle_eye_config_item.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import 'package:eagle_eye/model/config_keys.dart';
import 'package:eagle_eye/model/exceptions/invalid_config_exception.dart';

/// Represents a single rule item in the Eagle Eye configuration file.
///
/// Each [EagleEyeConfigItem] defines dependency constraints for files
Expand Down Expand Up @@ -38,72 +35,4 @@ class EagleEyeConfigItem {
required this.filePattern,
this.name,
});

/// Maps a single JSON object into an [EagleEyeConfigItem].
///
/// Throws an [InvalidConfigException] when a required field is missing or
/// when a field has an unexpected type.
factory EagleEyeConfigItem.fromJson(Map<String, dynamic> json) {
final dependenciesAllowed = _readDependenciesAllowed(json);
final forbiddenDependencies = _readStringList(
json,
ConfigKeys.forbiddenDependenciesEagleItemKey,
);
final exclusiveDependencies = _readStringList(
json,
ConfigKeys.exclusiveDependenciesEagleItemKey,
);
final filePattern = _readFilePattern(json);
final name = _readName(json);

return EagleEyeConfigItem(
dependenciesAllowed: dependenciesAllowed,
forbiddenDependencies: forbiddenDependencies,
exclusiveDependencies: exclusiveDependencies,
filePattern: filePattern,
name: name,
);
}

static bool? _readDependenciesAllowed(Map<String, dynamic> json) {
final key = ConfigKeys.dependenciesAllowedEagleItemKey;
final value = json[key];
if (value != null && value is! bool) {
throw InvalidConfigException('The "$key" value must be a boolean.');
}
return value as bool?;
}

static List<String>? _readStringList(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) {
return null;
}
if (value is! List || value.any((item) => item is! String)) {
throw InvalidConfigException(
'The "$key" value must be a list of strings.',
);
}
return List<String>.from(value);
}

static String _readFilePattern(Map<String, dynamic> json) {
final key = ConfigKeys.filePatternEagleItemKey;
final value = json[key];
if (value is! String || value.trim().isEmpty) {
throw InvalidConfigException(
'The "$key" value is required and must be a non-empty string.',
);
}
return value;
}

static String? _readName(Map<String, dynamic> json) {
final key = ConfigKeys.nameEagleItemKey;
final value = json[key];
if (value != null && value is! String) {
throw InvalidConfigException('The "$key" value must be a string.');
}
return value as String?;
}
}
Loading
Loading