Skip to content

Commit

Permalink
feat: support listing cycles in dependencies (#491)
Browse files Browse the repository at this point in the history
  • Loading branch information
blaugold authored Mar 9, 2023
1 parent 352d68e commit 6521ce0
Show file tree
Hide file tree
Showing 8 changed files with 88 additions and 22 deletions.
8 changes: 8 additions & 0 deletions docs/commands/list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,11 @@ Show dependency graph in Graphviz DOT language. Defaults to `false`.
```bash
melos list --gviz
```

## --cycles

Find cycles in package dependencies in the workspace.

```bash
melos list --cycles
```
7 changes: 7 additions & 0 deletions packages/melos/lib/src/command_runner/list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class ListCommand extends MelosCommand {
negatable: false,
help: 'Show dependency graph in Graphviz DOT language.',
);
argParser.addFlag(
'cycles',
negatable: false,
help: 'Find cycles in package dependencies in the workspace.',
);
}

@override
Expand All @@ -80,6 +85,7 @@ class ListCommand extends MelosCommand {
final relative = argResults!['relative'] as bool;
final graph = argResults!['graph'] as bool;
final gviz = argResults!['gviz'] as bool;
final cycles = argResults!['cycles'] as bool;

final melos = Melos(logger: logger, config: config);

Expand All @@ -89,6 +95,7 @@ class ListCommand extends MelosCommand {
if (json) kind = ListOutputKind.json;
if (graph) kind = ListOutputKind.graph;
if (gviz) kind = ListOutputKind.gviz;
if (cycles) kind = ListOutputKind.cycles;

return melos.list(
long: long,
Expand Down
4 changes: 3 additions & 1 deletion packages/melos/lib/src/commands/exec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ mixin _ExecMixin on _Melos {
final sortedPackages = packages.toList(growable: false);

if (orderDependents) {
sortPackagesTopologically(sortedPackages);
// TODO: This is not really the right way to do this. Cyclic dependencies
// are handled in a way that is specific for publishing.
sortPackagesForPublishing(sortedPackages);
}

final packageResults = Map.fromEntries(
Expand Down
20 changes: 19 additions & 1 deletion packages/melos/lib/src/commands/list.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
part of 'runner.dart';

// TODO find better names
enum ListOutputKind { json, parsable, graph, gviz, column }
enum ListOutputKind { json, parsable, graph, gviz, column, cycles }

mixin _ListMixin on _Melos {
Future<void> list({
Expand Down Expand Up @@ -36,6 +36,8 @@ mixin _ListMixin on _Melos {
);
case ListOutputKind.gviz:
return _listGviz(workspace);
case ListOutputKind.cycles:
return _listCyclesInDependencies(workspace);
}
}

Expand Down Expand Up @@ -256,4 +258,20 @@ mixin _ListMixin on _Melos {

logger.stdout(buffer.join('\n'));
}

Future<void> _listCyclesInDependencies(MelosWorkspace workspace) async {
final cycles = findCyclicDependenciesInWorkspace(
workspace.filteredPackages.values.toList(),
);

if (cycles.isEmpty) {
logger.stdout('🎉 No cycles in dependencies found.');
} else {
logger.stdout('🚨 ${cycles.length} cycles in dependencies found:');
for (final cycle in cycles) {
logger
.stdout('[ ${cycle.map((package) => package.name).join(' -> ')} ]');
}
}
}
}
2 changes: 1 addition & 1 deletion packages/melos/lib/src/commands/publish.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ mixin _PublishMixin on _ExecMixin {
return;
}

sortPackagesTopologically(unpublishedPackages);
sortPackagesForPublishing(unpublishedPackages);

logger
..newLine()
Expand Down
61 changes: 46 additions & 15 deletions packages/melos/lib/src/common/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'dart:isolate';

import 'package:ansi_styles/ansi_styles.dart';
import 'package:args/args.dart';
import 'package:collection/collection.dart' hide stronglyConnectedComponents;
import 'package:graphs/graphs.dart';
import 'package:path/path.dart' as p;
import 'package:prompts/prompts.dart' as prompts;
Expand Down Expand Up @@ -577,26 +578,56 @@ bool isPubSubcommand({required MelosWorkspace workspace}) {
}
}

/// Sorts packages in topological order so they may be published in the order
/// they're sorted.
/// Sorts [packages] in topological order so they can be published without
/// errors.
///
/// Packages with inter-dependencies cannot be topologically sorted and will
/// remain unchanged.
void sortPackagesTopologically(List<Package> packages) {
final packageNames = packages.map((el) => el.name).toList();
/// be sorted by name length. This is a heuristic to better handle cyclic
/// dependencies in federated plugins.
void sortPackagesForPublishing(List<Package> packages) {
final packageNames = packages.map((package) => package.name).toList();
final graph = <String, Iterable<String>>{
for (var package in packages)
package.name: package.dependencies.where(packageNames.contains),
package.name: [
...package.dependencies.where(packageNames.contains),
...package.devDependencies.where(packageNames.contains)
],
};
try {
final ordered = topologicalSort(graph.keys, (key) => graph[key]!);
packages.sort((a, b) {
// `ordered` is in reverse ordering to our desired publish precedence.
return ordered.indexOf(b.name).compareTo(ordered.indexOf(a.name));
});
} on CycleException<String> {
// Cannot sort packages with inter-dependencies. Leave as-is.
}
final ordered =
stronglyConnectedComponents(graph.keys, (package) => graph[package]!)
.expand(
(component) => component.sortedByCompare(
(package) => package.length,
(a, b) => b - a,
),
)
.toList();

packages.sort((a, b) {
return ordered.indexOf(a.name).compareTo(ordered.indexOf(b.name));
});
}

/// Returns a list of dependency cycles between [packages], taking into
/// account only workspace dependencies.
///
/// All dependencies between packages in the workspace are considered, including
/// `dev_dependencies` and `dependency_overrides`.
List<List<Package>> findCyclicDependenciesInWorkspace(List<Package> packages) {
return stronglyConnectedComponents(
packages,
(package) => package.allDependenciesInWorkspace.values,
).where((component) => component.length > 1).map((component) {
try {
topologicalSort(
component,
(package) => package.allDependenciesInWorkspace.values,
);
} on CycleException<Package> catch (error) {
return error.cycle;
}
throw StateError('Expected a cycle to be found.');
}).toList();
}

/// Given a workspace and package, this assembles the correct command to run pub
Expand Down
2 changes: 1 addition & 1 deletion packages/melos/lib/src/workspace_configs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ class VersionCommandConfigs {

if (changelogsYaml != null) {
for (var i = 0; i < changelogsYaml.length; i++) {
final entry = changelogsYaml[i]! as Map<String, Object?>;
final entry = changelogsYaml[i]! as Map<Object?, Object?>;

final path = assertKeyIsA<String>(
map: entry,
Expand Down
6 changes: 3 additions & 3 deletions packages/melos/test/commands/publish_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import 'package:test/scaffolding.dart';
void main() {
group('publish', () {
group('sort packages', () {
test('', () {
test('wo/ cyclic dependencies', () {
// b
// / \
// a d
Expand All @@ -24,7 +24,7 @@ void main() {
_dummyPackage('e', deps: ['f']),
]..shuffle();
final packageNames = packages.map((el) => el.name).toList();
sortPackagesTopologically(packages);
sortPackagesForPublishing(packages);

final published = <String>{};
for (final package in packages) {
Expand All @@ -44,7 +44,7 @@ void main() {
_dummyPackage('b', deps: ['a']),
];
expect(
() => sortPackagesTopologically(packages),
() => sortPackagesForPublishing(packages),
returnsNormally,
);
});
Expand Down

0 comments on commit 6521ce0

Please sign in to comment.