diff --git a/AGENTS.md b/AGENTS.md index eb3d52b..57387eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ dart test # run all tests dart test test/model/eagle_eye_config_item_test.dart # single file dart test -n "returns null" # single test by name dart run eagle_eye:main # run the CLI against the current project +dart run eagle_eye:main --verbose # trace every check performed ``` CI (`pr.yml`) runs `dart test` then `dart analyze` on PRs to `main`; both must diff --git a/CHANGELOG.md b/CHANGELOG.md index d4895d0..26b1806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Add a `--verbose` (`-v`) flag that prints every configuration, file, import, and pattern check performed by EagleEye. Add `--help` (`-h`) for usage information. + ## 2.0.5 - Fix Exclusive Dependencies Rule to depend only on certain files. An import is allowed when it matches **any** of the listed patterns, and a file that imports nothing is also allowed. diff --git a/README.md b/README.md index 0bbb52b..56dd455 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ dev_dependencies: dart run eagle_eye:main ``` +### Verbose mode + +Pass `--verbose` (or `-v`) to print every configuration, file, import, and +pattern check performed by Eagle Eye: + +```sh +dart run eagle_eye:main --verbose +``` + +Verbose lines are prefixed with `[verbose]` on stdout, so regular output stays +unchanged when the flag is omitted. Use `--help` to see all available options. + ### Auto-generate config with a prompt You can use [this prompt](./SETUP_PROMPT.md) with an AI assistant to automatically detect your architecture and generate an `eagle_eye_config.json` with the right rules. diff --git a/bin/main.dart b/bin/main.dart index 5726597..3cc87fb 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,5 +1,5 @@ import 'package:eagle_eye/eagle_eye_launcher.dart'; -void main() { - EagleEyeLauncher().launchEagleEye(); +void main(List args) { + EagleEyeLauncher().launchEagleEye(args: args); } diff --git a/doc/1-getting-started.md b/doc/1-getting-started.md index f178dd9..15ab463 100644 --- a/doc/1-getting-started.md +++ b/doc/1-getting-started.md @@ -194,6 +194,15 @@ dart run eagle_eye:main - **Exit code 0** with a success message: setup is correct. - **Exit code 1** with violations listed: the rules are working — review the violations to confirm they are real issues or adjust your config. +To see every configuration, file, import, and pattern check performed, run: + +```bash +dart run eagle_eye:main --verbose +``` + +Verbose lines are prefixed with `[verbose]`. Use `--help` (or `-h`) to list the +available options. + --- ## 6. Optional — integrate into CI/CD diff --git a/lib/analyzer/checker/exclusive_dependencies_rule_checker.dart b/lib/analyzer/checker/exclusive_dependencies_rule_checker.dart index 7a9b307..bba6582 100644 --- a/lib/analyzer/checker/exclusive_dependencies_rule_checker.dart +++ b/lib/analyzer/checker/exclusive_dependencies_rule_checker.dart @@ -1,4 +1,5 @@ import 'package:eagle_eye/analyzer/regex_helper.dart'; +import 'package:eagle_eye/util/check_reporter.dart'; /// Validates whether a file imports only allowed dependencies. /// @@ -9,9 +10,12 @@ class ExclusiveDependenciesRuleChecker { /// Helper used for regex-based pattern matching. RegexHelper regexHelper; + /// Optional callback used to report each pattern checked. + final CheckReporter? onCheck; + /// Creates a new [ExclusiveDependenciesRuleChecker] with the given - /// [regexHelper]. - ExclusiveDependenciesRuleChecker(this.regexHelper); + /// [regexHelper] and optional [onCheck] reporter. + ExclusiveDependenciesRuleChecker(this.regexHelper, {this.onCheck}); /// Checks if [importDirective] conforms to the specified /// [justWithPatterns] for the given [filePath]. @@ -28,6 +32,10 @@ class ExclusiveDependenciesRuleChecker { importDirective, justWithItem, ); + onCheck?.call( + ' allowed pattern "$justWithItem": ' + '${matches ? 'match -> allowed' : 'no match'}', + ); if (matches == true) { return null; } diff --git a/lib/analyzer/checker/forbidden_dependencies_rule_checker.dart b/lib/analyzer/checker/forbidden_dependencies_rule_checker.dart index 16ab4d9..244abc9 100644 --- a/lib/analyzer/checker/forbidden_dependencies_rule_checker.dart +++ b/lib/analyzer/checker/forbidden_dependencies_rule_checker.dart @@ -1,4 +1,5 @@ import 'package:eagle_eye/analyzer/regex_helper.dart'; +import 'package:eagle_eye/util/check_reporter.dart'; /// Validates whether a file imports disallowed dependencies. /// @@ -8,9 +9,12 @@ class ForbiddenDependenciesRuleChecker { /// Helper used for regex-based pattern matching. RegexHelper regexHelper; + /// Optional callback used to report each pattern checked. + final CheckReporter? onCheck; + /// Creates a new [ForbiddenDependenciesRuleChecker] with the given - /// [regexHelper]. - ForbiddenDependenciesRuleChecker(this.regexHelper); + /// [regexHelper] and optional [onCheck] reporter. + ForbiddenDependenciesRuleChecker(this.regexHelper, {this.onCheck}); /// Checks if [importDirective] violates any of [noDepsWithPatterns] /// for the given [filePath]. @@ -27,6 +31,10 @@ class ForbiddenDependenciesRuleChecker { importDirective, noDepsWithItem, ); + onCheck?.call( + ' forbidden pattern "$noDepsWithItem": ' + '${matches ? 'match -> violation' : 'no match'}', + ); if (matches == true) { return '$filePath should not depends on $importDirective'; } diff --git a/lib/analyzer/eagle_eye_matcher.dart b/lib/analyzer/eagle_eye_matcher.dart index d61c603..6742ef7 100644 --- a/lib/analyzer/eagle_eye_matcher.dart +++ b/lib/analyzer/eagle_eye_matcher.dart @@ -1,6 +1,7 @@ import 'package:eagle_eye/analyzer/regex_helper.dart'; import 'package:eagle_eye/model/eagle_eye_config.dart'; import 'package:eagle_eye/model/eagle_eye_config_item.dart'; +import 'package:eagle_eye/util/check_reporter.dart'; /// A matcher finds the configuration item that applies to a given file path. /// @@ -15,11 +16,15 @@ class EagleEyeMatcher { /// Helper class used to perform regex-based pattern matching. final RegexHelper regexHelper; - /// Creates a new [EagleEyeMatcher] instance with the provided [config] - /// and [regexHelper]. + /// Optional callback used to report each file pattern checked. + final CheckReporter? onCheck; + + /// Creates a new [EagleEyeMatcher] instance with the provided [config], + /// [regexHelper], and optional [onCheck] reporter. EagleEyeMatcher({ required this.config, required this.regexHelper, + this.onCheck, }); /// Finds the first [EagleEyeConfigItem] whose [filePattern] @@ -35,7 +40,14 @@ class EagleEyeMatcher { filePath, configurationItem.filePattern, ); + onCheck?.call( + ' filePattern "${configurationItem.filePattern}" vs "$filePath": ' + '${configItemFounded ? 'match' : 'no match'}', + ); if (configItemFounded) { + final ruleLabel = + configurationItem.name ?? configurationItem.filePattern; + onCheck?.call(' rule "$ruleLabel" selected'); return configurationItem; } } diff --git a/lib/analyzer/eagle_eye_visitor.dart b/lib/analyzer/eagle_eye_visitor.dart index 08d0cca..4462ef6 100644 --- a/lib/analyzer/eagle_eye_visitor.dart +++ b/lib/analyzer/eagle_eye_visitor.dart @@ -5,6 +5,7 @@ import 'package:eagle_eye/analyzer/checker/forbidden_dependencies_rule_checker.d import 'package:eagle_eye/analyzer/regex_helper.dart'; import 'package:eagle_eye/model/eagle_eye_config_item.dart'; import 'package:eagle_eye/model/violation.dart'; +import 'package:eagle_eye/util/check_reporter.dart'; /// A visitor that analyzes import directives to validate architectural rules /// defined in an [EagleEyeConfigItem]. @@ -39,8 +40,11 @@ class EagleEyeVisitor extends RecursiveAstVisitor { /// The parsed compilation unit, used to resolve line/column numbers. final CompilationUnit unit; + /// Optional callback used to report each import and pattern checked. + final CheckReporter? onCheck; + /// Creates a new [EagleEyeVisitor] with the given configuration, - /// file path, callback, and helpers. + /// file path, callback, helpers, and optional [onCheck] reporter. EagleEyeVisitor({ required this.configItem, required this.filePath, @@ -48,6 +52,7 @@ class EagleEyeVisitor extends RecursiveAstVisitor { required this.regexHelper, required this.applicationName, required this.unit, + this.onCheck, }); @override @@ -55,71 +60,91 @@ class EagleEyeVisitor extends RecursiveAstVisitor { super.visitImportDirective(node); final importDirective = node.uri.stringValue; + final location = unit.lineInfo.getLocation(node.offset); + final lineNumber = location.lineNumber; + final columnNumber = location.columnNumber; + + if (importDirective == null) { + onCheck?.call(' skipping import without URI (line $lineNumber)'); + return; + } + + if (!importDirective.contains(applicationName)) { + onCheck?.call( + ' skipping external import "$importDirective" (line $lineNumber)', + ); + return; + } - if (importDirective?.contains(applicationName) == true) { - final location = unit.lineInfo.getLocation(node.offset); - final lineNumber = location.lineNumber; - final columnNumber = location.columnNumber; + onCheck?.call( + ' internal import "$importDirective" (line $lineNumber)', + ); - if (configItem.dependenciesAllowed == false) { + if (configItem.dependenciesAllowed == false) { + onCheck?.call(' dependenciesAllowed is false -> violation'); + errorCallback( + Violation( + ruleType: RuleType.noImportsAllowed, + filePath: filePath, + lineNumber: lineNumber, + columnNumber: columnNumber, + importUri: importDirective, + ruleName: configItem.name, + description: '$filePath should not contains any import.', + ), + ); + } else if (configItem.forbiddenDependencies != null) { + onCheck?.call(' rule type: forbiddenDependencies'); + final checker = ForbiddenDependenciesRuleChecker( + regexHelper, + onCheck: onCheck, + ); + String? errorDescription = checker.check( + noDepsWithPatterns: configItem.forbiddenDependencies!, + importDirective: importDirective, + filePath: filePath, + ); + + if (errorDescription != null) { errorCallback( Violation( - ruleType: RuleType.noImportsAllowed, + ruleType: RuleType.forbiddenDependency, filePath: filePath, lineNumber: lineNumber, columnNumber: columnNumber, importUri: importDirective, ruleName: configItem.name, - description: '$filePath should not contains any import.', + description: errorDescription, ), ); - } else if (configItem.forbiddenDependencies != null) { - if (importDirective != null) { - final checker = ForbiddenDependenciesRuleChecker(regexHelper); - String? errorDescription = checker.check( - noDepsWithPatterns: configItem.forbiddenDependencies!, - importDirective: importDirective, - filePath: filePath, - ); - - if (errorDescription != null) { - errorCallback( - Violation( - ruleType: RuleType.forbiddenDependency, - filePath: filePath, - lineNumber: lineNumber, - columnNumber: columnNumber, - importUri: importDirective, - ruleName: configItem.name, - description: errorDescription, - ), - ); - } - } - } else if (configItem.exclusiveDependencies != null) { - if (importDirective != null) { - final checker = ExclusiveDependenciesRuleChecker(regexHelper); - String? errorDescription = checker.check( - justWithPatterns: configItem.exclusiveDependencies!, - importDirective: importDirective, + } + } else if (configItem.exclusiveDependencies != null) { + onCheck?.call(' rule type: exclusiveDependencies'); + final checker = ExclusiveDependenciesRuleChecker( + regexHelper, + onCheck: onCheck, + ); + String? errorDescription = checker.check( + justWithPatterns: configItem.exclusiveDependencies!, + importDirective: importDirective, + filePath: filePath, + ); + + if (errorDescription != null) { + errorCallback( + Violation( + ruleType: RuleType.exclusiveDependency, filePath: filePath, - ); - - if (errorDescription != null) { - errorCallback( - Violation( - ruleType: RuleType.exclusiveDependency, - filePath: filePath, - lineNumber: lineNumber, - columnNumber: columnNumber, - importUri: importDirective, - ruleName: configItem.name, - description: errorDescription, - ), - ); - } - } + lineNumber: lineNumber, + columnNumber: columnNumber, + importUri: importDirective, + ruleName: configItem.name, + description: errorDescription, + ), + ); } + } else { + onCheck?.call(' no dependency constraint configured -> allowed'); } } } diff --git a/lib/eagle_eye_launcher.dart b/lib/eagle_eye_launcher.dart index f3aa3c1..56c61d8 100644 --- a/lib/eagle_eye_launcher.dart +++ b/lib/eagle_eye_launcher.dart @@ -12,6 +12,8 @@ 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/eagle_eye_exception.dart'; import 'package:eagle_eye/model/violation.dart'; +import 'package:eagle_eye/util/check_reporter.dart'; +import 'package:eagle_eye/util/cli_options.dart'; import 'package:eagle_eye/util/logger_helper.dart'; import 'package:eagle_eye/util/output_renderer.dart'; @@ -19,13 +21,14 @@ import 'package:eagle_eye/util/output_renderer.dart'; /// /// [EagleEyeLauncher] coordinates the analysis of a Dart project using /// the Eagle Eye tool. It performs the following steps: -/// 1. Loads the configuration file (`eagle_eye_config.json`) via +/// 1. Parses the command-line arguments. +/// 2. Loads the configuration file (`eagle_eye_config.json`) via /// [EagleEyeRepository]. -/// 2. Collects all Dart files in the `lib/` folder. -/// 3. Determines the application name from `pubspec.yaml`. -/// 4. Uses [EagleEyeMatcher] to find which rules apply to each file. -/// 5. Uses [EagleEyeVisitor] to analyze imports and collect violations. -/// 6. Logs errors using [LoggerHelper] and exits with a non-zero code if +/// 3. Collects all Dart files in the `lib/` folder. +/// 4. Determines the application name from `pubspec.yaml`. +/// 5. Uses [EagleEyeMatcher] to find which rules apply to each file. +/// 6. Uses [EagleEyeVisitor] to analyze imports and collect violations. +/// 7. Logs errors using [LoggerHelper] and exits with a non-zero code if /// violations exist. class EagleEyeLauncher { late EagleEyeRepository _repository; @@ -42,6 +45,9 @@ class EagleEyeLauncher { /// Launches the Eagle Eye analysis on the current Dart project. /// + /// Accepts the raw command-line [args]. Pass `--verbose` (or `-v`) to print + /// every configuration, file, import, and pattern check performed. + /// /// This method: /// - Loads the configuration file and Dart source files. /// - Validates internal imports according to the rules defined in the @@ -49,7 +55,23 @@ class EagleEyeLauncher { /// - Collects all errors and prints them to the console. /// - Exits the process with a non-zero code if violations are found or /// critical files are missing. - Future launchEagleEye() async { + Future launchEagleEye({List args = const []}) async { + late final CliOptions options; + try { + options = CliOptions.parse(args); + } on FormatException catch (e) { + LoggerHelper.printError(e.message); + exit(1); + } + + if (options.showHelp) { + print(options.usage); + return; + } + + final CheckReporter? onCheck = + options.verbose ? LoggerHelper.printVerbose : null; + try { EagleEyeConfig configFile = await _repository.getAndCheckIfConfigFileExists(); @@ -58,14 +80,24 @@ class EagleEyeLauncher { String applicationName = _repository.getApplicationName(); + onCheck?.call( + 'Loaded ${configFile.items.length} rule(s) from ' + '${EagleEyeRepositoryImpl.configFileName}', + ); + onCheck?.call('Application name: $applicationName'); + onCheck?.call('Found ${dartFiles.length} Dart file(s) in lib/'); + final EagleEyeMatcher matcher = EagleEyeMatcher( config: configFile, regexHelper: RegexHelper(), + onCheck: onCheck, ); List errors = []; for (final file in dartFiles) { + onCheck?.call('Checking ${file.path}'); + final result = parseFile( path: file.path, featureSet: FeatureSet.latestLanguageVersion(), @@ -79,11 +111,19 @@ class EagleEyeLauncher { errorCallback: (error) => errors.add(error), regexHelper: RegexHelper(), unit: result.unit, + onCheck: onCheck, ); result.unit.visitChildren(visitor); + } else { + onCheck?.call(' no rule matched; skipping file'); } } + onCheck?.call( + 'Checked ${dartFiles.length} file(s); ' + '${errors.length} violation(s) found', + ); + if (errors.isNotEmpty) { final output = OutputRenderer().format(errors); print(output); diff --git a/lib/util/check_reporter.dart b/lib/util/check_reporter.dart new file mode 100644 index 0000000..d67e4e5 --- /dev/null +++ b/lib/util/check_reporter.dart @@ -0,0 +1,6 @@ +/// Reports a verbose message describing a check performed by Eagle Eye. +/// +/// When verbose mode is enabled, a [CheckReporter] is passed through the +/// analyzer so every configuration, file, import, and pattern check can be +/// printed to the console. +typedef CheckReporter = void Function(String message); diff --git a/lib/util/cli_options.dart b/lib/util/cli_options.dart new file mode 100644 index 0000000..d7f6a2f --- /dev/null +++ b/lib/util/cli_options.dart @@ -0,0 +1,50 @@ +import 'package:args/args.dart'; + +/// Parsed command-line options accepted by Eagle Eye. +class CliOptions { + /// Whether verbose check tracing is enabled. + final bool verbose; + + /// Whether the user requested the usage information. + final bool showHelp; + + /// The usage information for the available options. + final String usage; + + /// Creates a new [CliOptions] instance. + const CliOptions({ + required this.verbose, + required this.showHelp, + required this.usage, + }); + + /// Parses the command-line [args]. + /// + /// Throws a [FormatException] when an unknown option is provided. + factory CliOptions.parse(List args) { + final parser = _buildParser(); + final results = parser.parse(args); + + return CliOptions( + verbose: results['verbose'] as bool, + showHelp: results['help'] as bool, + usage: parser.usage, + ); + } + + static ArgParser _buildParser() { + return ArgParser() + ..addFlag( + 'verbose', + abbr: 'v', + negatable: false, + help: 'Print every file, import, and pattern check performed.', + ) + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Print this usage information.', + ); + } +} diff --git a/lib/util/logger_helper.dart b/lib/util/logger_helper.dart index f351518..2e5b38a 100644 --- a/lib/util/logger_helper.dart +++ b/lib/util/logger_helper.dart @@ -9,6 +9,7 @@ class LoggerHelper { static const _red = '\x1B[31m'; static const _green = '\x1B[32m'; + static const _cyan = '\x1B[36m'; static const _reset = '\x1B[0m'; static const _eagleEmoji = '🦅'; @@ -43,4 +44,12 @@ class LoggerHelper { static void printWarning(String message) { print('⚠️ $message'); } + + /// Prints a verbose check message in cyan with a `[verbose]` prefix. + /// + /// Example: + /// `🦅 [verbose] message` + static void printVerbose(String message) { + print('$_eagleEmoji $_cyan[verbose]$_reset $message'); + } } diff --git a/pubspec.lock b/pubspec.lock index 7e9a78b..c24e938 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "8.4.1" args: - dependency: transitive + dependency: "direct main" description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 diff --git a/pubspec.yaml b/pubspec.yaml index 2b1507b..46e7875 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: analyzer: ^8.4.1 + args: ^2.7.0 yaml: ^3.1.4 dev_dependencies: diff --git a/test/analyzer/checker/exclusive_dependencies_rule_checker_test.dart b/test/analyzer/checker/exclusive_dependencies_rule_checker_test.dart index 1609fb0..8ea6bec 100644 --- a/test/analyzer/checker/exclusive_dependencies_rule_checker_test.dart +++ b/test/analyzer/checker/exclusive_dependencies_rule_checker_test.dart @@ -64,5 +64,24 @@ void main() { '[*_viewmodel.dart, *_widget.dart]', ); }); + + test('reports each exclusive pattern checked until a match', () { + final messages = []; + final checker = ExclusiveDependenciesRuleChecker( + RegexHelper(), + onCheck: messages.add, + ); + + checker.check( + justWithPatterns: ['*_viewmodel.dart', '*_widget.dart'], + importDirective: 'my_widget.dart', + filePath: 'lib/my_screen.dart', + ); + + expect(messages, [ + ' allowed pattern "*_viewmodel.dart": no match', + ' allowed pattern "*_widget.dart": match -> allowed', + ]); + }); }); } diff --git a/test/analyzer/checker/forbidden_dependencies_rule_checker_test.dart b/test/analyzer/checker/forbidden_dependencies_rule_checker_test.dart index 9c05026..14d601c 100644 --- a/test/analyzer/checker/forbidden_dependencies_rule_checker_test.dart +++ b/test/analyzer/checker/forbidden_dependencies_rule_checker_test.dart @@ -32,5 +32,24 @@ void main() { expect(result, isNull); }); + + test('reports each forbidden pattern checked until a match', () { + final messages = []; + final checker = ForbiddenDependenciesRuleChecker( + RegexHelper(), + onCheck: messages.add, + ); + + checker.check( + noDepsWithPatterns: ['*repository.dart', '*service.dart'], + importDirective: 'my_service.dart', + filePath: 'lib/my_screen.dart', + ); + + expect(messages, [ + ' forbidden pattern "*repository.dart": no match', + ' forbidden pattern "*service.dart": match -> violation', + ]); + }); }); } diff --git a/test/analyzer/eagle_eye_matcher_test.dart b/test/analyzer/eagle_eye_matcher_test.dart index 400e1e0..cd7162f 100644 --- a/test/analyzer/eagle_eye_matcher_test.dart +++ b/test/analyzer/eagle_eye_matcher_test.dart @@ -58,5 +58,30 @@ void main() { // The first matching item should be returned expect(result, equals(item1)); }); + + test('reports each file pattern checked', () { + final messages = []; + final item1 = EagleEyeConfigItem( + filePattern: '*widget.dart', + name: 'widgets', + ); + final item2 = EagleEyeConfigItem(filePattern: '*screen.dart'); + final config = EagleEyeConfig(items: [item1, item2]); + + final matcher = EagleEyeMatcher( + config: config, + regexHelper: regexHelper, + onCheck: messages.add, + ); + + final result = matcher.find('home_screen.dart'); + + expect(result, equals(item2)); + expect(messages, [ + ' filePattern "*widget.dart" vs "home_screen.dart": no match', + ' filePattern "*screen.dart" vs "home_screen.dart": match', + ' rule "*screen.dart" selected', + ]); + }); }); } diff --git a/test/analyzer/eagle_eye_visitor_test.dart b/test/analyzer/eagle_eye_visitor_test.dart new file mode 100644 index 0000000..1ae5fc9 --- /dev/null +++ b/test/analyzer/eagle_eye_visitor_test.dart @@ -0,0 +1,143 @@ +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:eagle_eye/analyzer/eagle_eye_visitor.dart'; +import 'package:eagle_eye/analyzer/regex_helper.dart'; +import 'package:eagle_eye/model/eagle_eye_config_item.dart'; +import 'package:eagle_eye/model/violation.dart'; +import 'package:test/test.dart'; + +void main() { + group('EagleEyeVisitor', () { + List analyze({ + required String source, + required EagleEyeConfigItem configItem, + required List messages, + }) { + final unit = parseString(content: source).unit; + final violations = []; + + final visitor = EagleEyeVisitor( + configItem: configItem, + filePath: 'lib/model/user.dart', + applicationName: 'my_app', + errorCallback: violations.add, + regexHelper: RegexHelper(), + unit: unit, + onCheck: messages.add, + ); + unit.visitChildren(visitor); + + return violations; + } + + test('logs internal imports and skips external imports', () { + final messages = []; + final violations = analyze( + source: "import 'package:my_app/data/repo.dart';\n" + "import 'package:flutter/material.dart';\n", + configItem: EagleEyeConfigItem( + filePattern: '*/model/*', + dependenciesAllowed: false, + name: 'models-no-imports', + ), + messages: messages, + ); + + expect(violations, hasLength(1)); + expect(violations.single.ruleType, RuleType.noImportsAllowed); + expect( + messages, + contains( + ' internal import "package:my_app/data/repo.dart" (line 1)', + ), + ); + expect( + messages, + contains(' dependenciesAllowed is false -> violation'), + ); + expect( + messages, + contains( + ' skipping external import "package:flutter/material.dart" ' + '(line 2)', + ), + ); + }); + + test('logs every forbidden pattern checked', () { + final messages = []; + final violations = analyze( + source: "import 'package:my_app/ui/home_screen.dart';\n", + configItem: EagleEyeConfigItem( + filePattern: '*/model/*', + forbiddenDependencies: ['*_view.dart', '*_screen.dart'], + name: 'models-no-screens', + ), + messages: messages, + ); + + expect(violations, hasLength(1)); + expect(violations.single.ruleType, RuleType.forbiddenDependency); + expect( + messages, + contains(' rule type: forbiddenDependencies'), + ); + expect( + messages, + contains(' forbidden pattern "*_view.dart": no match'), + ); + expect( + messages, + contains( + ' forbidden pattern "*_screen.dart": match -> violation', + ), + ); + }); + + test('logs every exclusive pattern checked', () { + final messages = []; + final violations = analyze( + source: "import 'package:my_app/ui/home_widget.dart';\n", + configItem: EagleEyeConfigItem( + filePattern: '*/model/*', + exclusiveDependencies: ['*_viewmodel.dart', '*_widget.dart'], + name: 'models-only-ui', + ), + messages: messages, + ); + + expect(violations, isEmpty); + expect( + messages, + contains(' rule type: exclusiveDependencies'), + ); + expect( + messages, + contains(' allowed pattern "*_viewmodel.dart": no match'), + ); + expect( + messages, + contains(' allowed pattern "*_widget.dart": match -> allowed'), + ); + }); + + test('reports a violation when no exclusive pattern matches', () { + final messages = []; + final violations = analyze( + source: "import 'package:my_app/data/repo.dart';\n", + configItem: EagleEyeConfigItem( + filePattern: '*/model/*', + exclusiveDependencies: ['*_viewmodel.dart', '*_widget.dart'], + name: 'models-only-ui', + ), + messages: messages, + ); + + expect(violations, hasLength(1)); + expect(violations.single.ruleType, RuleType.exclusiveDependency); + expect( + messages, + contains(' allowed pattern "*_widget.dart": no match'), + ); + }); + }); +} diff --git a/test/util/cli_options_test.dart b/test/util/cli_options_test.dart new file mode 100644 index 0000000..8de0728 --- /dev/null +++ b/test/util/cli_options_test.dart @@ -0,0 +1,45 @@ +import 'package:eagle_eye/util/cli_options.dart'; +import 'package:test/test.dart'; + +void main() { + group('CliOptions', () { + test('defaults to non-verbose without help', () { + final options = CliOptions.parse([]); + + expect(options.verbose, isFalse); + expect(options.showHelp, isFalse); + }); + + test('enables verbose with --verbose', () { + final options = CliOptions.parse(['--verbose']); + + expect(options.verbose, isTrue); + }); + + test('enables verbose with -v', () { + final options = CliOptions.parse(['-v']); + + expect(options.verbose, isTrue); + }); + + test('enables help with --help', () { + final options = CliOptions.parse(['--help']); + + expect(options.showHelp, isTrue); + }); + + test('throws FormatException for unknown options', () { + expect( + () => CliOptions.parse(['--unknown']), + throwsFormatException, + ); + }); + + test('exposes usage text for the supported options', () { + final options = CliOptions.parse([]); + + expect(options.usage, contains('--verbose')); + expect(options.usage, contains('--help')); + }); + }); +}