diff --git a/src/pkgcheck/checks/visibility.py b/src/pkgcheck/checks/visibility.py index f7238c765..44db30fdf 100644 --- a/src/pkgcheck/checks/visibility.py +++ b/src/pkgcheck/checks/visibility.py @@ -1,4 +1,5 @@ from collections import defaultdict +from itertools import groupby from operator import attrgetter from pkgcore.ebuild.atom import atom, transitive_use_atom @@ -7,8 +8,8 @@ from snakeoil.sequences import iflatten_func, iflatten_instance, stable_unique from snakeoil.strings import pluralism -from .. import addons, feeds, results -from . import Check +from .. import addons, feeds, results, sources +from . import Check, OptionalCheck, RepoCheck class FakeConfigurable: @@ -465,3 +466,86 @@ def process_depset(self, pkg, attr, depset, edepset, profiles): failures.update(required) if failures: yield profile, failures + + +class RdependCycle(results.VersionResult, results.Warning): + def __init__(self, cycle, **kwargs): + super().__init__(**kwargs) + self.cycle = cycle + + @property + def desc(self): + return f"cycle detected: {' -> '.join(self.cycle)}" + + +class RdependCycleCheck(RepoCheck, OptionalCheck): + _source = sources.PackageRepoSource + known_results = frozenset({RdependCycle}) + + def __init__(self, options, **kwargs): + super().__init__(options, **kwargs) + self.visited_packages: dict[str, frozenset[str]] = {} + self.repo = self.options.target_repo + self.no_cycle = set() + + def find_cycle(self, start_key: str): + path: list[str] = [] + visited: set[str] = set() + + def dfs(node): + visited.add(node) + path.append(node) + + for neighbor in sorted(self.visited_packages[node] - self.no_cycle): + if neighbor not in visited: + if result := dfs(neighbor): + return result + elif neighbor in path and neighbor == start_key: + # Found a cycle that ends at the start node + idx = path.index(start_key) + return path[idx:] + [start_key] + + path.pop() + self.no_cycle.add(node) + return None + + return dfs(start_key) + + def _collect_deps_graph(self, key: str, pkgset): + pkg_deps = { + pkg: { + f"{dep.key}:{dep.slot}" if dep.slot is not None else dep.key + for dep in pkg.rdepend + if isinstance(dep, atom) and not dep.blocks + } + for pkg in pkgset + } + + if key in self.visited_packages: + return pkg_deps + + self.visited_packages[key] = all_deps = frozenset().union(*pkg_deps.values()) + if missing := all_deps - self.visited_packages.keys(): + for missing_key in missing: + try: + self._collect_deps_graph(missing_key, self.repo.match(atom(missing_key))) + except IndexError: # NonexistentDeps, invalid dep + self.visited_packages[missing_key] = frozenset() + return pkg_deps + + def _collect_graph_variants(self, pkgset): + self._collect_deps_graph(key := pkgset[0].key, pkgset) + yield key, pkgset + + def key_func(x): + return f"{x.key}:{x.slot}" + + for key, pkgs in groupby(sorted(pkgset, key=key_func), key_func): + self._collect_deps_graph(key, pkgs) + yield key, pkgs + + def feed(self, pkgset): + for key, pkg_deps in self._collect_graph_variants(pkgset): + if (cycle := self.find_cycle(key)) and cycle[-1] == key: + for pkg in pkg_deps: + yield RdependCycle(cycle, pkg=pkg)