Skip to content

Commit

Permalink
Add cyclic dependency checks.
Browse files Browse the repository at this point in the history
  • Loading branch information
dam5s committed Aug 13, 2023
1 parent 389a686 commit d8d1be4
Show file tree
Hide file tree
Showing 31 changed files with 835 additions and 17 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@ jobs:
with:
flutter-version: '3.x'
channel: 'stable'
- run: cd weather_app; flutter pub get
- run: make check
- run: make
36 changes: 31 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,39 @@ tasks: ## Print available tasks
@printf "\nUsage: make [target]\n\n"
@grep -E '^[a-z][^:]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'

.PHONY: format
format: ## Format code
.PHONY: cyclic_dependency_checks/install
cyclic_dependency_checks/install: ## Fetch dependencies for cyclic_dependency_checks
cd cyclic_dependency_checks; dart pub get

.PHONY: cyclic_dependency_checks/format
cyclic_dependency_checks/format: ## Format cyclic_dependency_checks code
cd cyclic_dependency_checks; dart format lib --line-length 100 --set-exit-if-changed

.PHONY: cyclic_dependency_checks/test
cyclic_dependency_checks/test: ## Run cyclic_dependency_checks tests
cd cyclic_dependency_checks; dart scripts/generate_big_codebase.dart; dart test

.PHONY: weather_app/install
weather_app/install: ## Fetch dependencies for weather_app
cd weather_app; flutter pub get

.PHONY: weather_app/format
weather_app/format: ## Format weather_app code
cd weather_app; dart format lib --line-length 100 --set-exit-if-changed

.PHONY: test
test: ## Run tests
.PHONY: weather_app/test
weather_app/test: ## Run weather_app tests
cd weather_app; flutter test

.PHONY: format
format: cyclic_dependency_checks/format weather_app/format ## Format all code

.PHONY: test
test: cyclic_dependency_checks/test weather_app/test ## Run all tests

.PHONY: check-cycles
check-cycles: ## Test cyclic dependencies
cd cyclic_dependency_checks; dart lib/main.dart ../weather_app

.PHONY: check
check: format test ## Check formatting and run tests
check: format test check-cycles ## Check formatting and run tests
55 changes: 55 additions & 0 deletions cyclic_dependency_checks/lib/cycle_detection/cycle_detector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'dart:io';

import 'imported_dependency.dart';
import 'module_dependency.dart';
import 'module_dependency_graph.dart';

class CycleDetector {
Future<List<List<ModuleDependency>>> detect(String packagePath, {int? maxDepth}) async {
final pubspecFile = File('$packagePath/pubspec.yaml');
final pubspecContent = await pubspecFile.readAsLines();
final appPackage = pubspecContent.firstOrNull?.replaceFirst('name: ', '');
final libPath = '$packagePath/lib';

if (appPackage == null) {
throw Exception('Could not read appPackage from file ${pubspecFile.absolute.path}');
}

final graph = ModuleDependencyGraph(appPackage: appPackage);

await for (final entity in Directory(libPath).list(recursive: true)) {
final entityPath = entity.path;

if (!entityPath.endsWith('.dart')) {
continue;
}

final content = await File(entityPath).readAsString();
final relativePath = entityPath.replaceFirst(libPath, '');
final source = SourceFile(relativePath);

final importResults = content
.split('\n')
.where((line) => line.startsWith('import '))
.map((line) => ImportedDependency.tryCreate(appPackage, source, line));

for (var res in importResults) {
if (res is DisallowedNestedImport) {
throw Exception('Found a disallowed dependency, from $entityPath to ${res.import}');
}
}

final validImports = importResults.expand((res) {
if (res is ValidImport) {
return [res.dependency];
} else {
return <ImportedDependency>[];
}
}).toList();

graph.addAll(validImports);
}

return graph.detectCycles(maxDepth: maxDepth);
}
}
115 changes: 115 additions & 0 deletions cyclic_dependency_checks/lib/cycle_detection/imported_dependency.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'module_dependency.dart';

class ImportedDependency {
final String appPackage;
final SourceFile source;
final String import;
final Module sourceModule;
final Module importedModule;

ImportedDependency._(
this.appPackage,
this.source,
this.import,
this.sourceModule,
this.importedModule,
);

@override
String toString() => 'src: ${source.path}, import: $import';

static DependencyCreationResult tryCreate(
String appPackage, SourceFile source, String importLine) {
final import = importLine.split("'")[1];
final isStandardLibrary = import.startsWith('dart:');
final isPackageDependency = import.startsWith('package:');
final isAppPackageDependency = import.startsWith('package:$appPackage');

if (isStandardLibrary) {
return IgnoredStandardLibrary(import);
}

if (isPackageDependency && !isAppPackageDependency) {
return IgnoredExternalPackage(import);
}

final isRelative = !isAppPackageDependency;
final isNested = import.contains('/');

if (isRelative && isNested) {
return DisallowedNestedImport(import);
}

if (isRelative) {
return IgnoredInnerImport(import);
}

final sourceModule = _sourceModule(appPackage, source);
final importedModule = _importedModule(appPackage, import, sourceModule);

if (importedModule == sourceModule) {
return IgnoredInnerImport(import);
}

return ValidImport(ImportedDependency._(
appPackage,
source,
import,
sourceModule,
importedModule,
));
}

static Module _sourceModule(String appPackage, SourceFile source) {
final components = source.path.split('/')
..removeLast()
..removeAt(0)
..insert(0, appPackage);
final path = components.join('/');

return Module(path);
}

static Module _importedModule(String appPackage, String import, Module sourceModule) {
final components = import.replaceFirst('package:', '').split('/')..removeLast();
return Module(components.join('/'));
}
}

class SourceFile {
final String path;

SourceFile(this.path);
}

sealed class DependencyCreationResult {}

class ValidImport implements DependencyCreationResult {
final ImportedDependency dependency;

ValidImport(this.dependency);
}

class IgnoredStandardLibrary implements DependencyCreationResult {
final String import;

IgnoredStandardLibrary(this.import);
}

class IgnoredExternalPackage implements DependencyCreationResult {
final String import;

IgnoredExternalPackage(this.import);
}

class DisallowedNestedImport implements DependencyCreationResult {
final String import;

DisallowedNestedImport(this.import);
}

class IgnoredInnerImport implements DependencyCreationResult {
final String import;

IgnoredInnerImport(this.import);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
extension Flatten<T> on List<List<T>> {
List<T> flatten() {
final result = <T>[];
for (final list in this) {
result.addAll(list);
}
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Module {
final String path;

Module(this.path);

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Module && runtimeType == other.runtimeType && path == other.path;

@override
int get hashCode => path.hashCode;

@override
String toString() => 'module:$path';
}

class ModuleDependency {
final Module from;
final Module to;

ModuleDependency({required this.from, required this.to});

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ModuleDependency &&
runtimeType == other.runtimeType &&
from == other.from &&
to == other.to;

@override
int get hashCode => from.hashCode ^ to.hashCode;

@override
String toString() => '$from -> $to';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'dart:isolate';

import 'package:cyclic_dependency_checks/cycle_detection/list_extensions.dart';

import 'imported_dependency.dart';
import 'module_dependency.dart';

class ModuleDependencyGraph {
final String appPackage;
final List<ImportedDependency> _dependencies = [];
final Set<ModuleDependency> _moduleDependencies = {};

ModuleDependencyGraph({required this.appPackage});

void addAll(List<ImportedDependency> dependencies) {
_dependencies.addAll(dependencies);
_moduleDependencies.addAll(dependencies.map(
(d) => ModuleDependency(from: d.sourceModule, to: d.importedModule),
));
}

Future<List<List<ModuleDependency>>> detectCycles({int? maxDepth}) async {
final moduleCount = _countModules();
final actualMaxDepth = maxDepth ?? moduleCount;

final resultsFutures = _moduleDependencies.map((value) => Isolate.run(
() => _detectDependencyCycles([value], maxDepth: actualMaxDepth),
));

return (await Future.wait(resultsFutures)).flatten();
}

int _countModules() {
final allModules = <Module>{};
for (var value in _moduleDependencies) {
allModules.addAll([value.from, value.to]);
}
return allModules.length;
}

Future<List<List<ModuleDependency>>> _detectDependencyCycles(
List<ModuleDependency> currentPath, {
required int maxDepth,
}) async {
if (currentPath.hasCycle()) {
return [currentPath];
}

if (currentPath.length - 1 > maxDepth) {
return [];
}

final last = currentPath.last;
final nextOptions = _moduleDependencies.where((dep) => dep.from == last.to);

final resultsFutures = nextOptions.map((next) => _detectDependencyCycles(
List.from(currentPath)..add(next),
maxDepth: maxDepth,
));

return (await Future.wait(resultsFutures)).flatten();
}

@override
String toString() => _moduleDependencies.map((d) => d.toString()).join('\n');
}

extension CycleDetection on List<ModuleDependency> {
List<Module> path() => map((dep) => dep.from).toList()..add(last.to);

bool hasCycle() {
final allModules = path();
return allModules.length > allModules.toSet().length;
}
}
42 changes: 42 additions & 0 deletions cyclic_dependency_checks/lib/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'dart:async';
import 'dart:io';

import 'cycle_detection/cycle_detector.dart';
import 'cycle_detection/module_dependency.dart';
import 'cycle_detection/module_dependency_graph.dart';

void main(List<String> args) async {
switch (args) {
case [final path]:
await _run(path);
default:
throw Exception(
'Expected exactly only one argument, '
'the path to dart package folder as argument',
);
}
}

Future _run(String path) async {
final stopwatch = Stopwatch()..start();
final cycles = await CycleDetector().detect(path);
stopwatch.stop();

final formattedTime = stopwatch.elapsed.toString().substring(0, 11);

if (cycles.isNotEmpty) {
stderr.writeln('Detected cycles after ${formattedTime}');
for (var cycle in cycles) {
cycle.printError();
}
exit(1);
}

stdout.writeln('No import cycles were detected after ${formattedTime}');
}

extension ErrorPrinting on List<ModuleDependency> {
void printError() {
stderr.writeln(path().join(' -> '));
}
}
Loading

0 comments on commit d8d1be4

Please sign in to comment.