Merge pull request #1296 from arjantijms/osgi_diagnosis

Improved OSGi failure diagnosis
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java
new file mode 100644
index 0000000..f561c12
--- /dev/null
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java
@@ -0,0 +1,922 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.jvnet.hk2.osgiadapter;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.BundleRequirement;
+import org.osgi.framework.wiring.BundleRevision;
+
+import static org.osgi.framework.namespace.PackageNamespace.RESOLUTION_DYNAMIC;
+import static org.osgi.resource.Namespace.EFFECTIVE_RESOLVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_EFFECTIVE_DIRECTIVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_FILTER_DIRECTIVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_RESOLUTION_DIRECTIVE;
+import static org.osgi.resource.Namespace.RESOLUTION_MANDATORY;
+import static org.osgi.resource.Namespace.RESOLUTION_OPTIONAL;
+
+/**
+ * Explains why a bundle cannot be resolved by asking the OSGi resolver model instead of reading the prose of a {@link BundleException}.
+ * <p>
+ * One missing package cascades: in a GlassFish installation a single absent export leaves a hundred bundles unresolved, and every one of
+ * them is unresolved for the same reason. This class therefore reports the shortest path from the bundle that failed to the requirement
+ * that nothing can satisfy, and stops there. It does not describe the cascade, because the cascade is not the answer.
+ * <p>
+ * A requirement is only a root cause when it is {@link Reason#NOT_DECLARED} - nothing in the framework offers the capability - or
+ * {@link Reason#DECLARATIONS_REJECTED} - the capability is offered but no declaration satisfies the filter, which is nearly always a
+ * version range. A requirement whose provider merely happens to be unresolved as well is a step along the path, never the destination.
+ * <p>
+ * Two things this cannot see:
+ * <ul>
+ * <li>{@link BundleRequirement#matches(BundleCapability)} compares attributes against the filter and ignores {@code uses} constraints. A
+ * bundle whose every requirement is satisfiable and which still does not resolve is reported as
+ * {@link Reason#NO_UNSATISFIED_REQUIREMENT}, which in practice means a class space conflict.</li>
+ * <li>Declared capabilities and requirements are used rather than the wiring, because an unresolved bundle has no wiring. Fragments
+ * attached at resolve time therefore contribute nothing here.</li>
+ * </ul>
+ */
+public final class BundleResolutionAnalyzer {
+
+    private static final int MAX_ROOT_CAUSES = 5;
+    private static final int MAX_CANDIDATES_SHOWN = 5;
+    private static final int MAX_BUNDLES_PER_CAUSE = 5;
+    private static final int MAX_CAUSES_SHOWN = 10;
+    private static final int MAX_NEAREST_SHOWN = 3;
+
+    private static final char SEGMENT_SEPARATOR = '.';
+    private static final char PATH_SEPARATOR = '/';
+
+    /** How many jars are worth opening for one missing package, and how many hits are worth printing. */
+    private static final int MAX_SUSPECTS_PROBED = 8;
+    private static final int MAX_SUSPECTS_SHOWN = 3;
+    private static final int MIN_SHARED_SEGMENTS = 2;
+    private static final int INDENT_WIDTH = 4;
+
+    private BundleResolutionAnalyzer() {
+    }
+
+    /**
+     * Why a requirement cannot be met.
+     */
+    public enum Reason {
+
+        /** No bundle in the framework declares a capability under the name the requirement asks for. */
+        NOT_DECLARED,
+
+        /** The name is declared, but no declaration satisfies the filter. Nearly always a version range that does not overlap. */
+        DECLARATIONS_REJECTED,
+
+        /** A declaration satisfies the filter, but every bundle providing it is itself unresolved. A step, never a root cause. */
+        WAITING_ON_PROVIDER,
+
+        /** Every mandatory requirement can be satisfied, yet the bundle is not resolved. Look for a uses constraint conflict. */
+        NO_UNSATISFIED_REQUIREMENT
+    }
+
+    /**
+     * How a declared name relates to the one a requirement asked for and could not find.
+     */
+    public enum Relation {
+
+        /** The declared name is a parent of the wanted one, so the requirement is for a subpackage nobody exports. */
+        ANCESTOR,
+
+        /** The declared name sits below the wanted one. */
+        DESCENDANT,
+
+        /** The declared name shares a parent with the wanted one. */
+        SIBLING,
+
+        /** Nothing is related by name, and the namespace is small enough to simply list what it does hold. */
+        SAME_NAMESPACE
+    }
+
+    /**
+     * A name that is declared, offered when the one asked for is not. The point is to distinguish a requirement on something that never
+     * existed from a requirement on an unexported corner of something that does.
+     */
+    public record NearestName(String name, Relation relation, int distance, List<Candidate> declaredBy) {
+
+        @Override
+        public String toString() {
+            return name + " (" + relation + ")";
+        }
+    }
+
+    /**
+     * A capability that was considered for a requirement, with the bundle declaring it.
+     */
+    public record Candidate(Bundle bundle, BundleCapability capability) {
+
+        @Override
+        public String toString() {
+            return describe(bundle) + " declares " + attributesOf(capability);
+        }
+    }
+
+    /**
+     * One hop along the path to a root cause: a bundle and the requirement of it that could not be met.
+     */
+    public record Step(Bundle bundle, BundleRequirement requirement) {
+
+        @Override
+        public String toString() {
+            if (requirement == null) {
+                return describe(bundle);
+            }
+
+            return describe(bundle) + " requires " + describe(requirement);
+        }
+    }
+
+    /**
+     * A requirement nothing can satisfy, and the shortest path from the bundle that was asked about to the bundle declaring it. The last
+     * step of the path is the failing one; the steps before it are the bundles that are only waiting on it.
+     */
+    public record RootCause(List<Step> path, Reason reason, List<Candidate> candidates, List<NearestName> nearest,
+            List<Bundle> privateIn) {
+
+        public Bundle bundle() {
+            return path.get(path.size() - 1).bundle();
+        }
+
+        public BundleRequirement requirement() {
+            return path.get(path.size() - 1).requirement();
+        }
+
+        /**
+         * One line, so that printing a list of these stays readable. {@link #explain(BundleContext, Bundle)} is the rendering meant for a
+         * person to read.
+         */
+        @Override
+        public String toString() {
+            StringBuilder text = new StringBuilder(160);
+
+            for (Step step : path) {
+                if (!text.isEmpty()) {
+                    text.append(" -> ");
+                }
+                text.append(step.bundle().getSymbolicName()).append(" [").append(step.bundle().getBundleId()).append(']');
+            }
+
+            if (requirement() != null) {
+                text.append(" requires ").append(describe(requirement()));
+            }
+
+            return text.append(" - ").append(summaryOf(reason)).toString();
+        }
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Public API
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    /**
+     * Explains a single bundle, which is what belongs in the message of a failure to start it.
+     *
+     * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+     * @param bundle The bundle that would not resolve.
+     * @return A short report, a handful of lines, naming the requirement that nothing can satisfy.
+     */
+    public static String explain(BundleContext bundleContext, Bundle bundle) {
+        if (isResolved(bundle)) {
+            return describe(bundle) + " is resolved.\n";
+        }
+
+        List<RootCause> rootCauses = findRootCauses(bundleContext, bundle);
+        if (rootCauses.isEmpty()) {
+            return describe(bundle) + " declares no mandatory requirement that cannot be met.\n";
+        }
+
+        StringBuilder reportBuilder = new StringBuilder(1024);
+        reportBuilder.append(describe(bundle)).append(" cannot resolve.\n");
+
+        for (RootCause rootCause : rootCauses) {
+            reportBuilder.append('\n');
+            append(rootCause, 1, reportBuilder);
+        }
+
+        return reportBuilder.toString();
+    }
+
+    /**
+     * Answers whether a bundle's jar holds a package without exporting it - a private package, in bnd's terms. That is the difference
+     * between a dependency on something that was never shipped and a dependency on an unexported corner of something that was, and the
+     * two have completely different fixes.
+     *
+     * Only the bundle's own jar is searched. A package embedded in a nested jar on the Bundle-ClassPath is not found, so a false answer
+     * means "not seen", never "definitely absent".
+     *
+     * @param bundle The bundle to look inside.
+     * @param packageName The package to look for, in dotted form.
+     * @return True when the jar holds at least one entry directly in that package. False on anything unreadable, because a guess here
+     *             would be worse than silence.
+     */
+    public static boolean containsPackage(Bundle bundle, String packageName) {
+        String path = packageName.replace(SEGMENT_SEPARATOR, PATH_SEPARATOR);
+
+        try {
+            // getEntryPaths does not attempt to resolve the bundle the way findEntries does. We are reporting a resolution failure
+            // already, so provoking another one on the side would be careless.
+            Enumeration<String> entries = bundle.getEntryPaths(path);
+            if (entries != null && entries.hasMoreElements()) {
+                return true;
+            }
+
+            // getEntryPaths works off the file entries, so it finds the package even in a jar that carries no directory entries. The
+            // directory lookup only adds the case of a package directory that exists but holds nothing.
+            return bundle.getEntry(path + PATH_SEPARATOR) != null;
+        } catch (RuntimeException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Finds the requirements that nothing in the framework can satisfy, reachable from a bundle, nearest first.
+     * <p>
+     * The search is breadth first over the bundles that a failing requirement leads to, and it stops descending as soon as a bundle
+     * explains itself, so the path returned is the shortest one. Every bundle is examined at most once.
+     *
+     * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+     * @param bundle The bundle to start from.
+     * @return The root causes, nearest first, never more than a handful. Empty when the bundle has no current revision.
+     */
+    public static List<RootCause> findRootCauses(BundleContext bundleContext, Bundle bundle) {
+        return findRootCauses(bundle, CapabilityIndex.of(bundleContext));
+    }
+
+    /**
+     * Summarises the whole framework: the distinct requirements that nothing can satisfy, the bundles blocked by each of them, and a count
+     * of the bundles that are only waiting on those. This replaces describing every unresolved bundle, which says the same thing once per
+     * bundle.
+     *
+     * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+     * @return A report of at most a few dozen lines however large the cascade is.
+     */
+    public static String explainUnresolvedBundles(BundleContext bundleContext) {
+        CapabilityIndex index = CapabilityIndex.of(bundleContext);
+
+        List<Bundle> unresolved = new ArrayList<>();
+        for (Bundle bundle : bundleContext.getBundles()) {
+            if (!isResolved(bundle) && bundle.getState() != Bundle.UNINSTALLED) {
+                unresolved.add(bundle);
+            }
+        }
+
+        if (unresolved.isEmpty()) {
+            return "All installed bundles are resolved.\n";
+        }
+
+        // Bundles that fail on their own are the causes. The rest are either waiting on one of those, or have nothing wrong with them.
+        Map<String, List<RootCause>> causes = new LinkedHashMap<>();
+        int waiting = 0;
+        int satisfiable = 0;
+
+        for (Bundle bundle : unresolved) {
+            List<Verdict> verdicts = verdicts(bundle, index);
+            if (verdicts.isEmpty()) {
+                satisfiable++;
+                continue;
+            }
+
+            List<Verdict> terminal = verdicts.stream().filter(verdict -> verdict.reason() != Reason.WAITING_ON_PROVIDER).toList();
+            if (terminal.isEmpty()) {
+                waiting++;
+                continue;
+            }
+
+            for (Verdict verdict : terminal) {
+                RootCause rootCause = new RootCause(List.of(new Step(bundle, verdict.requirement())), verdict.reason(),
+                    verdict.candidates(), verdict.nearest(), verdict.privateIn());
+                causes.computeIfAbsent(headline(rootCause), headline -> new ArrayList<>()).add(rootCause);
+            }
+        }
+
+        return summarise(bundleContext, unresolved.size(), causes, waiting, satisfiable);
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Search
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    /** A requirement of one bundle that could not be met, and the capabilities that were considered for it. */
+    private record Verdict(BundleRequirement requirement, Reason reason, List<Candidate> candidates, List<NearestName> nearest,
+            List<Bundle> privateIn) {
+    }
+
+    /** A bundle still to examine, with the path that led to it. */
+    private record Frontier(List<Step> path, Bundle bundle) {
+    }
+
+    private static List<RootCause> findRootCauses(Bundle bundle, CapabilityIndex index) {
+        List<RootCause> rootCauses = new ArrayList<>();
+
+        Set<Long> visited = new LinkedHashSet<>();
+        visited.add(bundle.getBundleId());
+
+        Deque<Frontier> frontier = new ArrayDeque<>();
+        frontier.add(new Frontier(List.of(), bundle));
+
+        while (!frontier.isEmpty() && rootCauses.size() < MAX_ROOT_CAUSES) {
+            Frontier current = frontier.removeFirst();
+
+            if (current.bundle().adapt(BundleRevision.class) == null) {
+                continue;
+            }
+
+            List<Verdict> verdicts = verdicts(current.bundle(), index);
+            if (verdicts.isEmpty()) {
+                rootCauses.add(new RootCause(extend(current, null), Reason.NO_UNSATISFIED_REQUIREMENT, List.of(), List.of(), List.of()));
+                continue;
+            }
+
+            List<Verdict> terminal = verdicts.stream().filter(verdict -> verdict.reason() != Reason.WAITING_ON_PROVIDER).toList();
+            if (!terminal.isEmpty()) {
+                // This bundle explains itself, so there is nothing to gain by walking past it.
+                for (Verdict verdict : terminal) {
+                    rootCauses.add(new RootCause(extend(current, verdict.requirement()), verdict.reason(), verdict.candidates(),
+                        verdict.nearest(), verdict.privateIn()));
+                }
+                continue;
+            }
+
+            for (Verdict verdict : verdicts) {
+                for (Candidate candidate : verdict.candidates()) {
+                    if (visited.add(candidate.bundle().getBundleId())) {
+                        frontier.addLast(new Frontier(extend(current, verdict.requirement()), candidate.bundle()));
+                    }
+                }
+            }
+        }
+
+        return rootCauses;
+    }
+
+    private static List<Step> extend(Frontier frontier, BundleRequirement requirement) {
+        List<Step> path = new ArrayList<>(frontier.path());
+        path.add(new Step(frontier.bundle(), requirement));
+
+        return List.copyOf(path);
+    }
+
+    private static List<Verdict> verdicts(Bundle bundle, CapabilityIndex index) {
+        BundleRevision revision = bundle.adapt(BundleRevision.class);
+        if (revision == null) {
+            return List.of();
+        }
+
+        List<Verdict> verdicts = new ArrayList<>();
+        for (BundleRequirement requirement : revision.getDeclaredRequirements(null)) {
+            if (!isMandatory(requirement)) {
+                continue;
+            }
+
+            Verdict verdict = judge(bundle, requirement, index);
+            if (verdict != null) {
+                verdicts.add(verdict);
+            }
+        }
+
+        return verdicts;
+    }
+
+    /**
+     * @return Null when the requirement can be met, otherwise why it cannot.
+     */
+    private static Verdict judge(Bundle bundle, BundleRequirement requirement, CapabilityIndex index) {
+        CapabilityIndex.Candidates candidates = index.candidatesFor(requirement);
+
+        List<Candidate> matching = new ArrayList<>();
+        List<Candidate> rejected = new ArrayList<>();
+
+        for (BundleCapability capability : candidates.capabilities()) {
+            Bundle provider = capability.getRevision().getBundle();
+
+            if (requirement.matches(capability)) {
+                // A bundle that exports what it imports - the substitutable export bnd generates for every export - satisfies itself.
+                if (isResolved(provider) || provider.getBundleId() == bundle.getBundleId()) {
+                    return null;
+                }
+                matching.add(new Candidate(provider, capability));
+            } else if (candidates.sameName()) {
+                rejected.add(new Candidate(provider, capability));
+            }
+        }
+
+        if (!matching.isEmpty()) {
+            return new Verdict(requirement, Reason.WAITING_ON_PROVIDER, matching, List.of(), List.of());
+        }
+
+        if (!rejected.isEmpty()) {
+            return new Verdict(requirement, Reason.DECLARATIONS_REJECTED, rejected, List.of(), List.of());
+        }
+
+        List<NearestName> nearest = index.nearestTo(requirement);
+
+        return new Verdict(requirement, Reason.NOT_DECLARED, List.of(), nearest, index.findPrivateHolders(requirement, nearest));
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Capability index
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    /**
+     * Every declared capability in the framework, indexed by namespace and by the name it carries, so that a requirement is only ever
+     * compared against the handful of capabilities that could conceivably match it.
+     * <p>
+     * Declared rather than wired, so that unresolved bundles - the whole point of this class - still contribute.
+     */
+    private static final class CapabilityIndex {
+
+        record Candidates(List<BundleCapability> capabilities, boolean sameName) {
+        }
+
+        private final Map<String, Map<String, List<BundleCapability>>> byName = new LinkedHashMap<>();
+        private final Map<String, List<BundleCapability>> byNamespace = new LinkedHashMap<>();
+        private final List<Bundle> bundles = new ArrayList<>();
+
+        static CapabilityIndex of(BundleContext bundleContext) {
+            CapabilityIndex index = new CapabilityIndex();
+
+            for (Bundle bundle : bundleContext.getBundles()) {
+                index.bundles.add(bundle);
+
+                BundleRevision revision = bundle.adapt(BundleRevision.class);
+                if (revision == null) {
+                    continue;
+                }
+
+                for (BundleCapability capability : revision.getDeclaredCapabilities(null)) {
+                    index.add(capability);
+                }
+            }
+
+            return index;
+        }
+
+        private void add(BundleCapability capability) {
+            String namespace = capability.getNamespace();
+            byNamespace.computeIfAbsent(namespace, key -> new ArrayList<>()).add(capability);
+
+            Object name = capability.getAttributes().get(namespace);
+            if (name != null) {
+                byName.computeIfAbsent(namespace, key -> new LinkedHashMap<>())
+                      .computeIfAbsent(name.toString(), key -> new ArrayList<>())
+                      .add(capability);
+            }
+        }
+
+        /**
+         * @return The capabilities worth comparing against the requirement, and whether they are narrowed to the name it asks for. When
+         *             they are not, a capability that fails to match says nothing about how close it came.
+         */
+        Candidates candidatesFor(BundleRequirement requirement) {
+            String name = requiredName(requirement);
+            if (name == null) {
+                return new Candidates(byNamespace.getOrDefault(requirement.getNamespace(), List.of()), false);
+            }
+
+            Map<String, List<BundleCapability>> namespace = byName.getOrDefault(requirement.getNamespace(), Map.of());
+
+            return new Candidates(namespace.getOrDefault(name, List.of()), true);
+        }
+
+        /**
+         * Opens the jars most likely to hold the missing package and reports the ones that actually do.
+         *
+         * @param requirement The requirement whose name is declared nowhere.
+         * @param nearest What the name search turned up, whose declaring bundles are the first worth opening.
+         * @return The bundles holding the package without exporting it, at most a few.
+         */
+        List<Bundle> findPrivateHolders(BundleRequirement requirement, List<NearestName> nearest) {
+            String name = requiredName(requirement);
+            if (name == null) {
+                return List.of();
+            }
+
+            List<Bundle> holders = new ArrayList<>();
+            for (Bundle suspect : suspects(name, nearest)) {
+                if (holders.size() == MAX_SUSPECTS_SHOWN) {
+                    break;
+                }
+
+                if (containsPackage(suspect, name)) {
+                    holders.add(suspect);
+                }
+            }
+
+            return List.copyOf(holders);
+        }
+
+        /**
+         * Whoever declares the nearest name is the obvious jar to open. After that, any bundle whose symbolic name shares a long prefix
+         * with the package, which is the only lead left when a whole subtree is exported nowhere.
+         */
+        private List<Bundle> suspects(String name, List<NearestName> nearest) {
+            Map<Long, Bundle> suspects = new LinkedHashMap<>();
+
+            for (NearestName nearestName : nearest) {
+                for (Candidate candidate : nearestName.declaredBy()) {
+                    suspects.putIfAbsent(candidate.bundle().getBundleId(), candidate.bundle());
+                }
+            }
+
+            List<Bundle> byPrefix = new ArrayList<>();
+            for (Bundle bundle : bundles) {
+                if (bundle.getSymbolicName() != null && sharedSegments(bundle.getSymbolicName(), name) >= MIN_SHARED_SEGMENTS) {
+                    byPrefix.add(bundle);
+                }
+            }
+            byPrefix.sort(Comparator.comparingInt((Bundle bundle) -> sharedSegments(bundle.getSymbolicName(), name)).reversed());
+
+            for (Bundle bundle : byPrefix) {
+                if (suspects.size() == MAX_SUSPECTS_PROBED) {
+                    break;
+                }
+
+                suspects.putIfAbsent(bundle.getBundleId(), bundle);
+            }
+
+            return new ArrayList<>(suspects.values());
+        }
+
+        private static int sharedSegments(String left, String right) {
+            String[] leftSegments = left.split("\\.");
+            String[] rightSegments = right.split("\\.");
+
+            int shared = 0;
+            while (shared < leftSegments.length && shared < rightSegments.length && leftSegments[shared].equals(rightSegments[shared])) {
+                shared++;
+            }
+
+            return shared;
+        }
+
+        /**
+         * Looks for a declared name close to the one a requirement asked for and did not find. A declared ancestor is the strongest
+         * signal there is: it means the bundle imports a corner of something that is exported, which is what an import generated for an
+         * unexported internal package looks like.
+         *
+         * @param requirement The requirement whose name is declared nowhere.
+         * @return At most a few names, all related the same way, nearest relation first. Empty when nothing is close.
+         */
+        List<NearestName> nearestTo(BundleRequirement requirement) {
+            String name = requiredName(requirement);
+            Map<String, List<BundleCapability>> declared = byName.getOrDefault(requirement.getNamespace(), Map.of());
+
+            if (name == null || declared.isEmpty()) {
+                return List.of();
+            }
+
+            for (int dot = name.lastIndexOf(SEGMENT_SEPARATOR); dot > 0; dot = name.lastIndexOf(SEGMENT_SEPARATOR, dot - 1)) {
+                String ancestor = name.substring(0, dot);
+                if (declared.containsKey(ancestor)) {
+                    return List.of(toNearestName(ancestor, Relation.ANCESTOR, segments(name) - segments(ancestor),
+                        declared.get(ancestor)));
+                }
+            }
+
+            String below = name + SEGMENT_SEPARATOR;
+            List<NearestName> descendants = collect(declared, Relation.DESCENDANT, name, candidate -> candidate.startsWith(below));
+            if (!descendants.isEmpty()) {
+                return descendants;
+            }
+
+            int lastDot = name.lastIndexOf(SEGMENT_SEPARATOR);
+            if (lastDot > 0) {
+                String parent = name.substring(0, lastDot + 1);
+                List<NearestName> siblings = collect(declared, Relation.SIBLING, name,
+                    candidate -> candidate.startsWith(parent) && candidate.indexOf(SEGMENT_SEPARATOR, parent.length()) < 0);
+                if (!siblings.isEmpty()) {
+                    return siblings;
+                }
+            }
+
+            // Namespaces like osgi.ee hold a handful of names with no hierarchy at all, so listing them is the useful answer.
+            if (declared.size() <= MAX_NEAREST_SHOWN) {
+                return collect(declared, Relation.SAME_NAMESPACE, name, candidate -> true);
+            }
+
+            return List.of();
+        }
+
+        private static int segments(String name) {
+            int count = 1;
+            for (int index = name.indexOf(SEGMENT_SEPARATOR); index >= 0; index = name.indexOf(SEGMENT_SEPARATOR, index + 1)) {
+                count++;
+            }
+
+            return count;
+        }
+
+        private static List<NearestName> collect(Map<String, List<BundleCapability>> declared, Relation relation, String name,
+                Predicate<String> matches) {
+
+            List<NearestName> nearest = new ArrayList<>();
+            for (Map.Entry<String, List<BundleCapability>> entry : declared.entrySet()) {
+                if (nearest.size() == MAX_NEAREST_SHOWN) {
+                    return nearest;
+                }
+
+                if (matches.test(entry.getKey())) {
+                    nearest.add(toNearestName(entry.getKey(), relation, Math.abs(segments(entry.getKey()) - segments(name)),
+                        entry.getValue()));
+                }
+            }
+
+            return nearest;
+        }
+
+        private static NearestName toNearestName(String name, Relation relation, int distance, List<BundleCapability> capabilities) {
+            List<Candidate> declaredBy = new ArrayList<>();
+            for (BundleCapability capability : capabilities) {
+                if (declaredBy.size() == MAX_NEAREST_SHOWN) {
+                    break;
+                }
+                declaredBy.add(new Candidate(capability.getRevision().getBundle(), capability));
+            }
+
+            return new NearestName(name, relation, distance, List.copyOf(declaredBy));
+        }
+
+        /**
+         * Reads the name a requirement asks for out of the equality assertion in its filter directive.
+         * <p>
+         * The assertion is matched whole, parentheses included. Matching it loosely is what made an export of org.jboss.weld.annotated look
+         * like a near miss for a requirement on org.jboss.weld.annotated.enhanced, and with a prefix of the wanted name almost always
+         * present in a package hierarchy, that hid every genuinely absent capability behind a bundle that had nothing to do with it.
+         *
+         * @return The name, or null when the filter does not assert exactly one, in which case the caller falls back to the whole
+         *             namespace. A disjunction over several names lands here.
+         */
+        private static String requiredName(BundleRequirement requirement) {
+            String filter = requirement.getDirectives().get(REQUIREMENT_FILTER_DIRECTIVE);
+            if (filter == null) {
+                return null;
+            }
+
+            String assertion = "(" + requirement.getNamespace() + "=";
+
+            int start = filter.indexOf(assertion);
+            if (start < 0 || filter.indexOf(assertion, start + 1) >= 0) {
+                return null;
+            }
+
+            int end = filter.indexOf(')', start + assertion.length());
+            if (end < 0) {
+                return null;
+            }
+
+            return filter.substring(start + assertion.length(), end);
+        }
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Rendering
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    private static void append(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+        List<Step> path = rootCause.path();
+
+        for (int index = 0; index < path.size(); index++) {
+            Step step = path.get(index);
+            String arrow = index == 0 ? "" : "-> ";
+
+            printLine(reportBuilder, indent, arrow + describe(step.bundle()));
+            if (step.requirement() != null) {
+                printLine(reportBuilder, indent + 1, "requires " + describe(step.requirement()));
+            }
+        }
+
+        printLine(reportBuilder, indent + 1, describe(rootCause.reason()));
+        appendCandidates(rootCause, indent + 2, reportBuilder);
+        appendPresence(rootCause, indent + 1, reportBuilder);
+    }
+
+    /**
+     * Finding the package inside a jar answers the question outright, so the nearest name is not worth printing alongside it.
+     */
+    private static void appendPresence(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+        if (rootCause.privateIn().isEmpty()) {
+            appendNearest(rootCause, indent, reportBuilder);
+            return;
+        }
+
+        printLine(reportBuilder, indent, "but it is present, unexported, in:");
+        for (Bundle bundle : rootCause.privateIn()) {
+            printLine(reportBuilder, indent + 1, describe(bundle));
+        }
+    }
+
+    private static void appendNearest(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+        for (NearestName nearest : rootCause.nearest()) {
+            printLine(reportBuilder, indent, describe(nearest) + " " + nearest.name());
+
+            for (Candidate candidate : nearest.declaredBy()) {
+                printLine(reportBuilder, indent + 1, "declared by " + describe(candidate.bundle()));
+            }
+        }
+    }
+
+    private static void appendCandidates(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+        int shown = 0;
+        for (Candidate candidate : rootCause.candidates()) {
+            if (shown == MAX_CANDIDATES_SHOWN) {
+                printLine(reportBuilder, indent, (rootCause.candidates().size() - shown) + " further declarations not shown");
+                return;
+            }
+
+            printLine(reportBuilder, indent, candidate.toString());
+            shown++;
+        }
+    }
+
+    private static String summarise(BundleContext bundleContext, int unresolved, Map<String, List<RootCause>> causes, int waiting,
+            int satisfiable) {
+        StringBuilder reportBuilder = new StringBuilder(2048);
+        reportBuilder.append(unresolved).append(" of ").append(bundleContext.getBundles().length)
+                     .append(" installed bundles are unresolved.\n");
+
+        if (causes.isEmpty()) {
+            reportBuilder.append("\nNo bundle carries a requirement that nothing can satisfy, "
+                + "which points at a uses constraint rather than a missing capability.\n");
+
+            return reportBuilder.toString();
+        }
+
+        reportBuilder.append('\n').append(causes.size()).append(causes.size() == 1 ? " root cause:\n" : " distinct root causes:\n");
+
+        int causesShown = 0;
+        for (Map.Entry<String, List<RootCause>> cause : causes.entrySet()) {
+            if (causesShown == MAX_CAUSES_SHOWN) {
+                reportBuilder.append('\n').append(causes.size() - causesShown).append(" further root causes not shown.\n");
+                break;
+            }
+            causesShown++;
+
+            List<RootCause> blocked = cause.getValue();
+
+            reportBuilder.append('\n');
+            printLine(reportBuilder, 1, cause.getKey());
+            appendCandidates(blocked.get(0), 2, reportBuilder);
+            appendPresence(blocked.get(0), 2, reportBuilder);
+
+            int shown = 0;
+            for (RootCause rootCause : blocked) {
+                if (shown == MAX_BUNDLES_PER_CAUSE) {
+                    printLine(reportBuilder, 2, "and " + (blocked.size() - shown) + " further bundles");
+                    break;
+                }
+
+                printLine(reportBuilder, 2, "blocks " + describe(rootCause.bundle()));
+                shown++;
+            }
+        }
+
+        if (waiting > 0) {
+            reportBuilder.append('\n').append(waiting)
+                         .append(waiting == 1 ? " further bundle is unresolved only because it needs one of the bundles above.\n"
+                             : " further bundles are unresolved only because they need one of the bundles above.\n");
+        }
+
+        if (satisfiable > 0) {
+            reportBuilder.append('\n').append(satisfiable)
+                         .append(satisfiable == 1 ? " further bundle has every mandatory requirement satisfiable, so it is"
+                             : " further bundles have every mandatory requirement satisfiable, so they are")
+                         .append(" unresolved only because nothing has needed them yet, or because of a uses constraint.\n");
+        }
+
+        return reportBuilder.toString();
+    }
+
+    private static String headline(RootCause rootCause) {
+        return describe(rootCause.requirement()) + " - " + describe(rootCause.reason());
+    }
+
+    private static String describe(Bundle bundle) {
+        return bundle.getSymbolicName() + " " + bundle.getVersion() + " [" + bundle.getBundleId() + "] " + toStateName(bundle);
+    }
+
+    /**
+     * Describes a requirement by the filter directive the framework stored, rendered the same way {@link FelixPrettyPrinter} renders the
+     * one in the resolver message, so that the two reports agree rather than each inventing its own notation.
+     */
+    private static String describe(BundleRequirement requirement) {
+        String filter = requirement.getDirectives().get(REQUIREMENT_FILTER_DIRECTIVE);
+        if (filter == null) {
+            return requirement.getNamespace() + " " + requirement.getAttributes();
+        }
+
+        return FelixPrettyPrinter.formatFilter(filter);
+    }
+
+    /** The compact wording, for a reason that has to share a line with everything else. */
+    private static String summaryOf(Reason reason) {
+        return switch (reason) {
+            case NOT_DECLARED -> "not declared by any bundle";
+            case DECLARATIONS_REJECTED -> "declared, but no declaration satisfies it";
+            case WAITING_ON_PROVIDER -> "every bundle providing it is unresolved";
+            case NO_UNSATISFIED_REQUIREMENT -> "every requirement satisfiable, look for a uses constraint";
+        };
+    }
+
+    /**
+     * How far away the declared name is decides how much it is worth. A parent one level up says the requirement is for an unexported
+     * corner of a bundle that is right there; an ancestor three levels up says little more than that the tree exists.
+     */
+    private static String describe(NearestName nearest) {
+        return switch (nearest.relation()) {
+            case ANCESTOR -> nearest.distance() == 1 ? "but its parent is declared:"
+                : "but an ancestor " + nearest.distance() + " levels up is declared:";
+            case DESCENDANT -> nearest.distance() == 1 ? "but a name directly below it is declared:"
+                : "but a name " + nearest.distance() + " levels below is declared:";
+            case SIBLING -> "but a name alongside it is declared:";
+            case SAME_NAMESPACE -> "what this namespace does hold:";
+        };
+    }
+
+    private static String describe(Reason reason) {
+        return switch (reason) {
+            case NOT_DECLARED -> "no bundle in the framework declares that";
+            case DECLARATIONS_REJECTED -> "declared, but no declaration satisfies the requirement:";
+            case WAITING_ON_PROVIDER -> "a declaration satisfies it, but every bundle providing it is unresolved:";
+            case NO_UNSATISFIED_REQUIREMENT -> "every mandatory requirement can be satisfied - look for a uses constraint conflict";
+        };
+    }
+
+    /** The attributes of a capability without the one carrying its name, which the requirement line above already showed. */
+    private static String attributesOf(BundleCapability capability) {
+        Map<String, Object> attributes = new LinkedHashMap<>(capability.getAttributes());
+        attributes.remove(capability.getNamespace());
+
+        return attributes.toString();
+    }
+
+    private static void printLine(StringBuilder reportBuilder, int indent, String text) {
+        reportBuilder.append(" ".repeat(indent * INDENT_WIDTH)).append(text).append('\n');
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Model helpers
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    /**
+     * A requirement only blocks resolution when it is effective at resolve time and neither optional nor dynamic. Skipping the rest keeps
+     * the report free of the osgi.service requirements that Declarative Services adds, which are never resolved against.
+     */
+    private static boolean isMandatory(BundleRequirement requirement) {
+        Map<String, String> directives = requirement.getDirectives();
+
+        if (!EFFECTIVE_RESOLVE.equals(directives.getOrDefault(REQUIREMENT_EFFECTIVE_DIRECTIVE, EFFECTIVE_RESOLVE))) {
+            return false;
+        }
+
+        String resolution = directives.getOrDefault(REQUIREMENT_RESOLUTION_DIRECTIVE, RESOLUTION_MANDATORY);
+
+        return !RESOLUTION_OPTIONAL.equals(resolution) && !RESOLUTION_DYNAMIC.equals(resolution);
+    }
+
+    private static boolean isResolved(Bundle bundle) {
+        return (bundle.getState() & (Bundle.RESOLVED | Bundle.STARTING | Bundle.STOPPING | Bundle.ACTIVE)) != 0;
+    }
+
+    private static String toStateName(Bundle bundle) {
+        return switch (bundle.getState()) {
+            case Bundle.UNINSTALLED -> "UNINSTALLED";
+            case Bundle.INSTALLED -> "INSTALLED";
+            case Bundle.RESOLVED -> "RESOLVED";
+            case Bundle.STARTING -> "STARTING";
+            case Bundle.STOPPING -> "STOPPING";
+            case Bundle.ACTIVE -> "ACTIVE";
+            default -> "UNKNOWN";
+        };
+    }
+}
\ No newline at end of file
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
index 04d8865..21f8404 100644
--- a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
@@ -22,9 +22,7 @@
 import java.io.InputStreamReader;
 import java.net.URL;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Enumeration;
-import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -35,300 +33,826 @@
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.BundleException;
+import org.osgi.framework.Filter;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.Version;
 import org.osgi.framework.wiring.BundleRevision;
 import org.osgi.resource.Capability;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.osgi.framework.namespace.PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE;
 import static org.osgi.framework.namespace.PackageNamespace.PACKAGE_NAMESPACE;
 
 /**
- * Tools for obtaining readable information from the {@link BundleException}
+ * Tools for obtaining readable information from the {@link BundleException} thrown by the Felix resolver.
+ * <p>
+ * The message format of the resolver is not specified anywhere, so everything in this class is best effort. The one hard rule it follows is
+ * that it never destroys information: when a message cannot be understood completely, the original text is appended to the output instead of
+ * being silently dropped.
+ * <p>
+ * For a diagnosis that does not depend on the message format at all, see {@code BundleResolutionAnalyzer}, which asks the resolver model
+ * directly instead of reading its prose.
  */
-public class FelixPrettyPrinter {
+public final class FelixPrettyPrinter {
 
-    private static final Pattern BUNDLE_PATTERN = Pattern.compile("\\[(\\d+)\\]", Pattern.MULTILINE);
+    private static final Pattern BUNDLE_ID_PATTERN = Pattern.compile("\\[(\\d+)\\]");
 
-    public static String prettyPrintFelixMessage(BundleContext context, final String bundleMessage) {
-        final String prettyMessage = prettyPrintExceptionMessage(bundleMessage);
+    /** Comparison operators of an LDAP filter, longest first so that {@code >=} wins over {@code =}. */
+    private static final Pattern FILTER_OPERATOR_PATTERN = Pattern.compile(">=|<=|~=|=");
 
-        final StringBuilder bundleBuilder = new StringBuilder(1024);
-        bundleBuilder.append(prettyMessage);
+    private static final String UNABLE_TO_RESOLVE = "Unable to resolve";
+    private static final String MISSING_REQUIREMENT = "missing requirement";
+    private static final String CAUSED_BY = "caused by:";
+    private static final String REVISION_MARKER = "(R ";
 
-        List<Long> bundleIDs = new ArrayList<>();
+    /** Dropped when rendering a filter, so that osgi.wiring.package reads as package and osgi.wiring.host as host. */
+    private static final String WIRING_NAMESPACE_PREFIX = "osgi.wiring.";
 
-        bundleIDs.addAll(addExportingBundles(context, prettyMessage, bundleBuilder));
-        bundleIDs.addAll(findBundleIds(prettyMessage));
+    private static final String ORIGINAL_MESSAGE_MARKER = "--- original message, not fully understood by the pretty printer ---";
 
-        if (!bundleIDs.isEmpty()) {
-            for (Long bundleId : bundleIDs) {
-                Bundle bundle = context.getBundle(bundleId);
-                if (bundle != null) {
-                    bundleBuilder.append('[').append(bundleId).append("] \n");
-                    bundleBuilder.append("jar = ").append(bundle.getLocation());
-                    tryAddPomProperties(bundle, bundleBuilder);
-                    bundleBuilder.append('\n');
-                }
-            }
+    private static final int INDENT_WIDTH = 4;
+
+    /** A shaded bundle can carry dozens of Maven descriptors, which would drown the actual error. */
+    private static final int MAX_POM_PROPERTIES_PER_BUNDLE = 4;
+
+    public static void main(String[] args) {
+        System.out.println(prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.hk2.config-types [138](R 138.0): missing requirement [org.glassfish.main.hk2.config-types [138](R 138.0)] osgi.wiring.package; (&(osgi.wiring.package=org.jvnet.hk2.config)(version>=9.0.0)(!(version>=10.0.0))) [caused by: Unable to resolve org.glassfish.main.hk2-config-generator [159](R 159.0): missing requirement [org.glassfish.main.hk2-config-generator [159](R 159.0)] osgi.wiring.package; (&(osgi.wiring.package=org.hibernate.validator)(version>=9.1.0)(!(version>=10.0.0))) [caused by: Unable to resolve org.hibernate.validator [262](R 262.0): missing requirement [org.hibernate.validator [262](R 262.0)] osgi.wiring.package; (&(osgi.wiring.package=jakarta.validation)(version>=3.1.1)(!(version>=4.0.0)))]] Unresolved requirements: [[org.glassfish.main.hk2.config-types [138](R 138.0)] osgi.wiring.package; (&(osgi.wiring.package=org.jvnet.hk2.config)(version>=9.0.0)(!(version>=10.0.0)))]"));
+    }
+
+    private FelixPrettyPrinter() {
+    }
+
+    /**
+     * Renders a resolver message and appends everything known about the bundles it mentions, plus the bundles that export the packages that
+     * could not be wired.
+     *
+     * @param bundleContext The context used to resolve bundle ids and to look for exporters. Must not be null.
+     * @param bundleMessage The raw message of the {@link BundleException}. May be null or empty, in which case it is returned unchanged.
+     * @return A multiline, human readable rendering of the message.
+     */
+    public static String prettyPrintFelixMessage(BundleContext bundleContext, String bundleMessage) {
+        if (bundleMessage == null || bundleMessage.isEmpty()) {
+            return bundleMessage;
         }
 
-        return bundleBuilder.toString();
+        try {
+            List<ResolutionFailure> failures = parseFailures(bundleMessage);
+
+            StringBuilder messageBuilder = new StringBuilder(1024);
+            messageBuilder.append(format(failures, bundleMessage));
+
+            Set<Long> bundleIds = new LinkedHashSet<>();
+            bundleIds.addAll(appendExporters(bundleContext, failures, messageBuilder));
+            bundleIds.addAll(findBundleIds(messageBuilder.toString()));
+
+            for (Long bundleId : bundleIds) {
+                Bundle bundle = bundleContext.getBundle(bundleId);
+                if (bundle != null) {
+                    appendBundleInfo(bundle, messageBuilder);
+                }
+            }
+
+            return messageBuilder.toString();
+        } catch (RuntimeException e) {
+            // We are usually formatting another failure already - never turn that into a second one.
+            return bundleMessage;
+        }
     }
 
     /**
      * Prints exception messages from Felix bundle classloading in a more human readable way.
      *
-     * @param message - error message from the exception
-     * @return multiline human readable string
+     * @param message The error message from the exception. May be null or empty, in which case it is returned unchanged.
+     * @return A multiline human readable string, ending with the original message when the parse was incomplete.
      */
-    public static String prettyPrintExceptionMessage(final String message) {
-        StringBuilder messageBuilder = new StringBuilder(256);
+    public static String prettyPrintExceptionMessage(String message) {
+        if (message == null || message.isEmpty()) {
+            return message;
+        }
+
         try {
-            int index = message.indexOf("Unable to resolve");
-            int indent = 0;
-            while (index >= 0) {
-                printLn(messageBuilder, indent, "Unable to resolve");
-                index += "Unable to resolve".length();
-
-                int index2 = message.indexOf("missing requirement", index);
-                if (index2 >= 0) {
-
-                    indent++;
-
-                    // Module name would be e.g.
-                    // org.glassfish.server.internal.batch.glassfish-batch-connector [103](R 103.0):
-                    String module = message.substring(index, index2);
-
-                    // Remove the duplicate number
-                    if (module.contains("(R")) {
-                        module = module.substring(0, module.indexOf("(R"));
-                    }
-
-                    printLn(messageBuilder, indent, module);
-                    printLn(messageBuilder, indent, "missing requirement");
-
-                    index = index2 + "missing requirement".length();
-
-                    // In GlassFish and in a classloader the search is always for package, so we can
-                    // use that as a delimiter here
-                    int indexPackage = message.indexOf("osgi.wiring.package; ", index);
-                    int indexHost = message.indexOf("osgi.wiring.host; ", index);
-
-                    boolean hasPackage = indexPackage >= 0;
-                    boolean hasHost = indexHost >= 0;
-
-                    boolean isPackage = false;
-                    if (hasPackage && (!hasHost || indexPackage < indexHost)) {
-                        index = indexPackage;
-                        isPackage = true;
-                    } else if (hasHost) {
-                        index = indexHost;
-                    } else {
-                        index = -1;
-                    }
-
-                    if (index >= 0) {
-
-                        indent++;
-
-                        if (isPackage) {
-
-                            // Remainder of input now looks like this:
-
-                            // osgi.wiring.package; (&(osgi.wiring.package=org.glassfish.grizzly)(version>=2.4.0)(!(version>=3.0.0)))
-
-                            // Skip over "osgi.wiring.package; ", we're always searching for this so
-                            // no need to print it.
-                            index += "osgi.wiring.package; ".length();
-
-                            // Now extracting this:
-                            // "(&(osgi.wiring.package=org.glassfish.grizzly)(version>=2.4.0)(!(version>=3.0.0)))"
-                            index2 = message.indexOf(" ", index);
-
-                            String packageAndVersion = null;
-                            if (index2 != -1) {
-                                packageAndVersion = message.substring(index, index2);
-                            } else {
-                                packageAndVersion = message.substring(index);
-                            }
-
-                            // Make it a little less "cramped"
-                            // "(&(package=org.glassfish.grizzly) (version>=2.4.0) (!(version>=3.0.0)))"
-                            packageAndVersion = packageAndVersion.replace("osgi.wiring.package", "package");
-                            packageAndVersion = packageAndVersion.replace(")(", ") (");
-                            packageAndVersion = packageAndVersion.replace("=", " = ");
-                            packageAndVersion = packageAndVersion.replace("> =", " >=");
-                            packageAndVersion = packageAndVersion.replace("< =", " <=");
-
-                            // Remove outer braces
-                            // "&(package=org.glassfish.grizzly) (version>=2.4.0) (!(version>=3.0.0))"
-                            if (packageAndVersion.startsWith("(")) {
-                                packageAndVersion = packageAndVersion.substring(1);
-                            }
-                            if (packageAndVersion.endsWith(")")) {
-                                packageAndVersion = packageAndVersion.substring(0, packageAndVersion.length() - 1);
-                            }
-
-                            printLn(messageBuilder, indent, packageAndVersion);
-                        } else {
-
-                            // Remainder of input now looks like this:
-
-                            // osgi.wiring.host; (&(osgi.wiring.host=org.hibernate.validator)(bundle-version>=0.0.0)
-
-                            // Skip over "osgi.wiring.host; ", we're already searching for this so
-                            // no need to print it.
-                            index += "osgi.wiring.host; ".length();
-
-                            index2 = message.indexOf("]", index);
-
-                            String remainder = null;
-                            if (index2 != -1) {
-                                remainder = message.substring(index, index2);
-                            } else {
-                                remainder = message.substring(index);
-                            }
-
-                            printLn(messageBuilder, indent, remainder);
-                        }
-
-                        // If there's a "caused by:", print it and increase the indent
-                        index = message.indexOf("caused by: ", index2);
-                        if (index >= 0) {
-
-                            printLn(messageBuilder, indent, "caused by:");
-
-                            indent++;
-                            index += "caused by: ".length();
-                        }
-
-                    }
-                }
-
-                if (index2 == -1) {
-                    index = -1;
-                } else {
-                    index = index2;
-                    index = message.indexOf("Unable to resolve", index);
-                }
-            }
-            return messageBuilder.toString();
-        } catch (Exception e) {
-            // Usually we are processing another exception - if we failed, better return original.
+            return format(parseFailures(message), message);
+        } catch (RuntimeException e) {
+            // Usually we are processing another exception - if we failed, better return the original.
             return message;
         }
     }
 
+    /**
+     * Appends the location and the Maven coordinates of a single bundle to an already rendered message.
+     *
+     * @param bundle The bundle to describe. May be null, in which case only the message is returned.
+     * @param prettyMessage The already rendered message.
+     * @return The message with the bundle information appended.
+     */
     public static String addBundleInfo(Bundle bundle, String prettyMessage) {
-        final StringBuilder bundleBuilder = new StringBuilder(1024);
-        bundleBuilder.append('\n').append(prettyMessage);
+        StringBuilder messageBuilder = new StringBuilder(1024);
+        messageBuilder.append('\n').append(prettyMessage);
+
         if (bundle != null) {
-            bundleBuilder.append('[').append(bundle.getBundleId()).append("] \n");
-            bundleBuilder.append("jar = ").append(bundle.getLocation());
-            tryAddPomProperties(bundle, bundleBuilder);
-            bundleBuilder.append('\n');
+            appendBundleInfo(bundle, messageBuilder);
         }
 
-        return bundleBuilder.toString();
+        return messageBuilder.toString();
     }
 
-    private static List<Long> addExportingBundles(BundleContext context, String prettyMessage, StringBuilder bundleBuilder) {
-        Set<Bundle> exportingBundles = new HashSet<>();
-        List<Long> bundleIDs = new ArrayList<>();
+    /**
+     * @param message The error message from the exception.
+     * @return The distinct bundle ids found in the message, in the order they appear. They are the numbers in square brackets.
+     */
+    public static List<Long> findBundleIds(String message) {
+        if (message == null || message.isEmpty()) {
+            return List.of();
+        }
 
-        int lastPackageindex = prettyMessage.lastIndexOf("package = ");
-        if (lastPackageindex != -1) {
-            String lastPackage = prettyMessage.substring(lastPackageindex + "package = ".length(), prettyMessage.indexOf(")", lastPackageindex));
+        Set<Long> bundleIds = new LinkedHashSet<>();
+        Matcher bundleMatcher = BUNDLE_ID_PATTERN.matcher(message);
+        while (bundleMatcher.find()) {
+            bundleIds.add(Long.valueOf(bundleMatcher.group(1)));
+        }
 
-            exportingBundles.addAll(findExporters(context, lastPackage));
+        return new ArrayList<>(bundleIds);
+    }
 
-            if (exportingBundles.isEmpty()) {
-                bundleBuilder.append("\nNo bundles found to export " + lastPackage + "\n");
-            } else {
-                bundleBuilder.append("\nThe following bundles export \"" + lastPackage + "\"\n");
-                for (Bundle bundle : exportingBundles) {
-                    bundleIDs.add(bundle.getBundleId());
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Parsing
+    // -----------------------------------------------------------------------------------------------------------------------------------
 
-                    bundleBuilder.append(bundle.getSymbolicName())
-                                 .append(" ")
-                                 .append(bundle.getVersion())
-                                 .append(" [")
-                                 .append(bundle.getBundleId())
-                                 .append("]")
-                                 .append('\n')
-                                 ;
+    /**
+     * A single "Unable to resolve" clause. The namespace and the filter are null when that part of the clause could not be understood, which
+     * is what drives the decision to append the original message.
+     */
+    private record ResolutionFailure(String module, String namespace, String filter, boolean isCause) {
+
+        boolean isComplete() {
+            return namespace != null && filter != null;
+        }
+    }
+
+    /**
+     * Splits the message into one clause per "Unable to resolve". Every clause is parsed strictly within its own bounds, so a clause can
+     * never borrow the requirement of the clause nesting below it.
+     */
+    private static List<ResolutionFailure> parseFailures(String message) {
+        List<ResolutionFailure> failures = new ArrayList<>();
+
+        int previousIndex = -1;
+        int index = message.indexOf(UNABLE_TO_RESOLVE);
+        while (index >= 0) {
+            int clauseStart = index + UNABLE_TO_RESOLVE.length();
+
+            int clauseEnd = message.indexOf(UNABLE_TO_RESOLVE, clauseStart);
+            if (clauseEnd < 0) {
+                clauseEnd = message.length();
+            }
+
+            // Only nest a clause when the resolver said it is a cause. Sibling clauses stay at the level of the one before them.
+            boolean isCause = previousIndex >= 0 && message.lastIndexOf(CAUSED_BY, index) > previousIndex;
+            failures.add(parseFailure(message, clauseStart, clauseEnd, isCause));
+
+            previousIndex = index;
+            index = message.indexOf(UNABLE_TO_RESOLVE, clauseStart);
+        }
+
+        return failures;
+    }
+
+    /**
+     * Parses one clause, which looks like this:
+     *
+     * <pre>
+     * org.glassfish.batch-connector [103](R 103.0): missing requirement [org.glassfish.batch-connector [103](R 103.0)] osgi.wiring.package;
+     *     (&amp;(osgi.wiring.package=jakarta.batch)(version&gt;=2.1.0)(!(version&gt;=3.0.0)))
+     * </pre>
+     */
+    private static ResolutionFailure parseFailure(String message, int start, int end, boolean isCause) {
+        int requirementIndex = message.indexOf(MISSING_REQUIREMENT, start);
+        if (requirementIndex < 0 || requirementIndex >= end) {
+            return new ResolutionFailure(cleanModule(message.substring(start, end)), null, null, isCause);
+        }
+
+        String module = cleanModule(message.substring(start, requirementIndex));
+
+        // Skip the revision reference that repeats the module, for example "[org.glassfish.batch-connector [103](R 103.0)]".
+        int namespaceStart = skipRevisionReference(message, requirementIndex + MISSING_REQUIREMENT.length(), end);
+
+        int namespaceEnd = message.indexOf(';', namespaceStart);
+        if (namespaceEnd < 0 || namespaceEnd >= end) {
+            return new ResolutionFailure(module, null, null, isCause);
+        }
+
+        // Any namespace is accepted here: osgi.wiring.package, osgi.wiring.host, osgi.wiring.bundle, osgi.ee, osgi.native, ...
+        String namespace = message.substring(namespaceStart, namespaceEnd).trim();
+
+        return new ResolutionFailure(module, namespace, extractFilter(message, namespaceEnd + 1, end), isCause);
+    }
+
+    private static int skipRevisionReference(String message, int start, int end) {
+        int index = start;
+        while (index < end && Character.isWhitespace(message.charAt(index))) {
+            index++;
+        }
+
+        if (index >= end || message.charAt(index) != '[') {
+            return index;
+        }
+
+        // The reference nests, so counting is the only way to find its end.
+        int depth = 0;
+        while (index < end) {
+            char character = message.charAt(index);
+            if (character == '[') {
+                depth++;
+            } else if (character == ']') {
+                depth--;
+                if (depth == 0) {
+                    return index + 1;
                 }
             }
-            bundleBuilder.append('\n');
+            index++;
         }
 
-        return bundleIDs;
+        return start;
     }
 
-    private static List<Bundle> findExporters(BundleContext ctx, String packageName) {
-        List<Bundle> exporters = new ArrayList<>();
+    /**
+     * Extracts a complete LDAP filter by balancing parentheses. Splitting on the first space, as we used to do, breaks on any filter that
+     * contains one and on any filter that ends the message.
+     */
+    private static String extractFilter(String message, int start, int end) {
+        int open = start;
+        while (open < end && message.charAt(open) != '(') {
+            open++;
+        }
 
-        for (Bundle b : ctx.getBundles()) {
-            BundleRevision rev = b.adapt(BundleRevision.class);
-            if (rev == null) {
+        if (open >= end) {
+            return null;
+        }
+
+        int depth = 0;
+        for (int index = open; index < end; index++) {
+            char character = message.charAt(index);
+            if (character == '(') {
+                depth++;
+            } else if (character == ')') {
+                depth--;
+                if (depth == 0) {
+                    return message.substring(open, index + 1);
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /** Turns "org.glassfish.batch-connector [103](R 103.0): " into "org.glassfish.batch-connector [103]". */
+    private static String cleanModule(String module) {
+        String name = module.trim();
+
+        int revisionIndex = name.indexOf(REVISION_MARKER);
+        if (revisionIndex >= 0) {
+            name = name.substring(0, revisionIndex).trim();
+        }
+
+        if (name.endsWith(":")) {
+            name = name.substring(0, name.length() - 1).trim();
+        }
+
+        return name;
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Rendering
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    private static String format(List<ResolutionFailure> failures, String message) {
+        if (failures.isEmpty()) {
+            return message;
+        }
+
+        StringBuilder messageBuilder = new StringBuilder(256);
+
+        // Anything the framework put in front of the first clause is context we do not want to lose.
+        String prefix = message.substring(0, message.indexOf(UNABLE_TO_RESOLVE)).trim();
+        if (!prefix.isEmpty()) {
+            printLine(messageBuilder, 0, prefix);
+        }
+
+        int indent = 0;
+        for (ResolutionFailure failure : failures) {
+
+            // A cause belongs under the requirement that could not be met, which sits two levels below its own "Unable to resolve".
+            if (failure.isCause()) {
+                printLine(messageBuilder, indent + 2, CAUSED_BY);
+                indent += 3;
+            }
+
+            printLine(messageBuilder, indent, UNABLE_TO_RESOLVE);
+            printLine(messageBuilder, indent + 1, failure.module());
+
+            if (failure.namespace() != null) {
+                // The namespace is not repeated here, the filter below asserts on it and reads better for it.
+                printLine(messageBuilder, indent + 1, MISSING_REQUIREMENT);
+            }
+
+            if (failure.filter() != null) {
+                printLine(messageBuilder, indent + 2, formatFilter(failure.filter()));
+            }
+        }
+
+        if (!failures.stream().allMatch(ResolutionFailure::isComplete)) {
+            messageBuilder.append('\n').append(ORIGINAL_MESSAGE_MARKER).append('\n').append(message).append('\n');
+        }
+
+        return messageBuilder.toString();
+    }
+
+    /**
+     * Renders an LDAP filter the way it is normally read. The resolver prints prefix notation, so
+     * "(&(osgi.wiring.package=jakarta.batch)(version&gt;=2.1.0)(!(version&gt;=3.0.0)))" comes in as one dense line and goes out as
+     * "package = jakarta.batch &amp; version &gt;= 2.1.0 &amp; !(version &gt;= 3.0.0)".
+     *
+     * Package private so that {@code BundleResolutionAnalyzer} renders the filter it reads from a requirement directive the same way,
+     * rather than the two reports each inventing their own notation for the same thing.
+     *
+     * @param filter The raw filter as the resolver printed it.
+     * @return The filter in infix notation, or the filter merely spaced out when it does not follow the grammar.
+     */
+    static String formatFilter(String filter) {
+        String infix = new FilterParser(filter).parse();
+        if (infix != null) {
+            return infix;
+        }
+
+        // Not a filter we recognise. Show it spaced out rather than not at all.
+        return spaceOutFilter(filter);
+    }
+
+    private static String spaceOutFilter(String filter) {
+        String formatted = FILTER_OPERATOR_PATTERN.matcher(filter).replaceAll(" $0 ");
+        formatted = formatted.replace(")(", ") (");
+
+        return stripOuterParentheses(formatted).trim();
+    }
+
+    /**
+     * Shortens a namespace attribute, so that osgi.wiring.package reads as package and osgi.wiring.host as host. Namespaces without the
+     * wiring prefix, osgi.ee for instance, are left alone and stay recognisable.
+     */
+    private static String shortenNamespace(String namespace) {
+        if (namespace.startsWith(WIRING_NAMESPACE_PREFIX)) {
+            return namespace.substring(WIRING_NAMESPACE_PREFIX.length());
+        }
+
+        return namespace;
+    }
+
+    private static String stripOuterParentheses(String text) {
+        if (text.length() < 2 || text.charAt(0) != '(' || text.charAt(text.length() - 1) != ')') {
+            return text;
+        }
+
+        // Only strip when the first parenthesis is the one the last parenthesis closes, so that "(a) (b)" keeps both pairs and an
+        // unbalanced filter is handed back untouched.
+        int depth = 0;
+        for (int index = 0; index < text.length(); index++) {
+            char character = text.charAt(index);
+            if (character == '(') {
+                depth++;
+            } else if (character == ')') {
+                depth--;
+                if (depth == 0 && index < text.length() - 1) {
+                    return text;
+                }
+            }
+        }
+
+        if (depth != 0) {
+            return text;
+        }
+
+        return text.substring(1, text.length() - 1);
+    }
+
+    /**
+     * Recursive descent parser for the LDAP filter grammar of RFC 1960, rendering the result in infix notation.
+     * <p>
+     * The grammar is small. A filter is parenthesised and holds either a composite - "&amp;", "|" or "!" followed by nested filters - or a
+     * single item of the form attribute, operator, value. Rendering it infix means the operator ends up between its operands, and the
+     * parentheses that only carried the prefix grouping disappear.
+     * <p>
+     * Every method returns null on anything the grammar does not allow, which the caller turns into the untouched filter. Nothing here ever
+     * throws, because it runs while another failure is already being reported.
+     */
+    private static final class FilterParser {
+
+        /**
+         * A parsed subexpression. Composite subexpressions are the ones that need their grouping shown when they are nested inside another
+         * operator; a single item never does.
+         */
+        private record Expression(String text, boolean composite) {
+
+            String grouped() {
+                return composite ? "(" + text + ")" : text;
+            }
+        }
+
+        private final String filter;
+
+        private int position;
+
+        FilterParser(String filter) {
+            this.filter = filter;
+        }
+
+        /**
+         * @return The whole filter in infix notation, or null when it is not a filter this parser understands.
+         */
+        String parse() {
+            if (filter == null || filter.isEmpty()) {
+                return null;
+            }
+
+            Expression expression = parseFilter();
+            if (expression == null) {
+                return null;
+            }
+
+            skipWhitespace();
+            if (position != filter.length()) {
+                return null;
+            }
+
+            return expression.text();
+        }
+
+        private Expression parseFilter() {
+            skipWhitespace();
+            if (position >= filter.length() || filter.charAt(position) != '(') {
+                return null;
+            }
+            position++;
+
+            skipWhitespace();
+            if (position >= filter.length()) {
+                return null;
+            }
+
+            Expression expression = switch (filter.charAt(position)) {
+                case '&', '|' -> parseComposite(filter.charAt(position));
+                case '!' -> parseNegation();
+                default -> parseItem();
+            };
+
+            if (expression == null) {
+                return null;
+            }
+
+            skipWhitespace();
+            if (position >= filter.length() || filter.charAt(position) != ')') {
+                return null;
+            }
+            position++;
+
+            return expression;
+        }
+
+        private Expression parseComposite(char operator) {
+            position++;
+
+            StringBuilder expressionBuilder = new StringBuilder(64);
+            int operands = 0;
+
+            skipWhitespace();
+            while (position < filter.length() && filter.charAt(position) == '(') {
+                Expression operand = parseFilter();
+                if (operand == null) {
+                    return null;
+                }
+
+                if (operands > 0) {
+                    expressionBuilder.append(' ').append(operator).append(' ');
+                }
+                expressionBuilder.append(operand.grouped());
+                operands++;
+
+                skipWhitespace();
+            }
+
+            if (operands == 0) {
+                return null;
+            }
+
+            // A single operand needs no grouping of its own, whatever the operator in front of it was.
+            return new Expression(expressionBuilder.toString(), operands > 1);
+        }
+
+        private Expression parseNegation() {
+            position++;
+
+            Expression operand = parseFilter();
+            if (operand == null) {
+                return null;
+            }
+
+            return new Expression("!(" + operand.text() + ")", false);
+        }
+
+        private Expression parseItem() {
+            int attributeStart = position;
+            while (position < filter.length() && isAttributeCharacter(filter.charAt(position))) {
+                position++;
+            }
+
+            String attribute = filter.substring(attributeStart, position).trim();
+            if (attribute.isEmpty()) {
+                return null;
+            }
+
+            String operator = readOperator();
+            if (operator == null) {
+                return null;
+            }
+
+            return new Expression(shortenNamespace(attribute) + " " + operator + " " + readValue(), false);
+        }
+
+        private String readOperator() {
+            if (position >= filter.length()) {
+                return null;
+            }
+
+            char character = filter.charAt(position);
+            if (character == '=') {
+                position++;
+                return "=";
+            }
+
+            if (position + 1 >= filter.length() || filter.charAt(position + 1) != '=') {
+                return null;
+            }
+
+            position += 2;
+
+            return character + "=";
+        }
+
+        /**
+         * Reads a value up to the closing parenthesis of its item. A parenthesis inside a value has to be escaped, so a backslash always
+         * takes the character behind it along.
+         */
+        private String readValue() {
+            StringBuilder valueBuilder = new StringBuilder(32);
+
+            while (position < filter.length() && filter.charAt(position) != ')') {
+                char character = filter.charAt(position);
+
+                if (character == '\\' && position + 1 < filter.length()) {
+                    valueBuilder.append(character).append(filter.charAt(position + 1));
+                    position += 2;
+                    continue;
+                }
+
+                valueBuilder.append(character);
+                position++;
+            }
+
+            return valueBuilder.toString().trim();
+        }
+
+        private boolean isAttributeCharacter(char character) {
+            return character != '=' && character != '>' && character != '<' && character != '~'
+                    && character != '(' && character != ')';
+        }
+
+        private void skipWhitespace() {
+            while (position < filter.length() && Character.isWhitespace(filter.charAt(position))) {
+                position++;
+            }
+        }
+    }
+
+    private static void printLine(StringBuilder messageBuilder, int indent, String text) {
+        if (text == null || text.isBlank()) {
+            return;
+        }
+
+        messageBuilder.append(" ".repeat(indent * INDENT_WIDTH)).append(text.trim()).append('\n');
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Bundle information
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    /**
+     * Reports, for every distinct package that could not be wired, which bundles export it, at which version, and whether that export
+     * actually satisfies the requirement. The package name and the filter come from the parse, not from re-reading our own output.
+     *
+     * @return The ids of the exporting bundles, so the caller can describe them once.
+     */
+    private static Set<Long> appendExporters(BundleContext bundleContext, List<ResolutionFailure> failures, StringBuilder messageBuilder) {
+        Set<Long> bundleIds = new LinkedHashSet<>();
+        Set<String> reportedPackages = new LinkedHashSet<>();
+
+        for (ResolutionFailure failure : failures) {
+            if (!PACKAGE_NAMESPACE.equals(failure.namespace()) || failure.filter() == null) {
                 continue;
             }
 
-            List<Capability> caps = rev.getCapabilities(PACKAGE_NAMESPACE);
-            for (Capability cap : caps) {
-                Map<String, Object> attrs = cap.getAttributes();
-                Object exportedPkg = attrs.get(PACKAGE_NAMESPACE);
+            String packageName = findRequiredPackage(failure.filter());
+            if (packageName == null || !reportedPackages.add(packageName)) {
+                continue;
+            }
 
-                if (packageName.equals(exportedPkg)) {
-                    exporters.add(b);
-                    break; // one match is enough per bundle
-                }
+            List<PackageExport> exports = findExporters(bundleContext, packageName, createFilter(failure.filter()));
+            if (exports.isEmpty()) {
+                messageBuilder.append("\nNo bundle exports \"").append(packageName).append("\"\n");
+                continue;
+            }
+
+            messageBuilder.append("\nThe following bundles export \"").append(packageName).append("\"\n");
+            for (PackageExport export : exports) {
+                bundleIds.add(export.bundle().getBundleId());
+                appendExport(export, packageName, messageBuilder);
             }
         }
 
-        return exporters;
+        return bundleIds;
     }
 
-    private static void tryAddPomProperties(Bundle bundle, StringBuilder bundleBuilder) {
+    private static void appendExport(PackageExport export, String packageName, StringBuilder messageBuilder) {
+        messageBuilder.append("  ")
+                      .append(export.match().label())
+                      .append(' ')
+                      .append(export.bundle().getSymbolicName())
+                      .append(' ')
+                      .append(export.bundle().getVersion())
+                      .append(" [")
+                      .append(export.bundle().getBundleId())
+                      .append("] ")
+                      .append(toStateName(export.bundle()))
+                      .append(" - exports ")
+                      .append(packageName)
+                      // The bundle version above and the exported package version below are routinely different.
+                      .append(" at version ")
+                      .append(export.packageVersion())
+                      .append('\n');
+    }
+
+    private static void appendBundleInfo(Bundle bundle, StringBuilder messageBuilder) {
+        messageBuilder.append('[').append(bundle.getBundleId()).append("]\n");
+        messageBuilder.append("jar = ").append(bundle.getLocation());
+        appendPomProperties(bundle, messageBuilder);
+        messageBuilder.append('\n');
+    }
+
+    private static void appendPomProperties(Bundle bundle, StringBuilder messageBuilder) {
+        // Note: findEntries is specified to attempt to resolve the bundle so that fragment entries can be searched. We are calling it while
+        // reporting a resolution failure, which is why it is the last thing we do and why every failure below is swallowed.
         Enumeration<URL> entries = bundle.findEntries("META-INF/maven/", "pom.properties", true);
         if (entries == null) {
             return;
         }
 
-        while (entries.hasMoreElements()) {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(entries.nextElement().openStream(), UTF_8))) {
+        int printed = 0;
+        while (entries.hasMoreElements() && printed < MAX_POM_PROPERTIES_PER_BUNDLE) {
+            URL entry = entries.nextElement();
+            printed++;
+
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(entry.openStream(), UTF_8))) {
                 reader.lines()
-                      .filter(e -> !e.startsWith("#"))
-                      .forEach(e -> bundleBuilder.append('\n').append(e.replace("=", " = ")));
-            } catch (IOException e1) {
-                // Ignore
+                      .map(String::trim)
+                      .filter(line -> !line.isEmpty() && !line.startsWith("#"))
+                      .forEach(line -> messageBuilder.append('\n').append(line.replace("=", " = ")));
+            } catch (IOException e) {
+                // The content is unreadable, the location printed above is all we can offer.
             }
-            bundleBuilder.append('\n');
+        }
+
+        if (entries.hasMoreElements()) {
+            messageBuilder.append("\n... further Maven coordinates omitted");
         }
     }
 
+    // -----------------------------------------------------------------------------------------------------------------------------------
+    // Exporter lookup
+    // -----------------------------------------------------------------------------------------------------------------------------------
+
+    private enum RequirementMatch {
+
+        MATCHES("[matches] "),
+        MISMATCH("[mismatch]"),
+        UNKNOWN("[unknown] ");
+
+        private final String label;
+
+        RequirementMatch(String label) {
+            this.label = label;
+        }
+
+        String label() {
+            return label;
+        }
+    }
+
+    private record PackageExport(Bundle bundle, Version packageVersion, RequirementMatch match) {
+    }
 
     /**
-     * @param message - error message from the exception
-     * @return list of bundle ids (are in square brackets in the message)
+     * Finds every bundle that declares an export of the package, resolved or not, and lets the framework itself decide whether that export
+     * satisfies the requirement. Declared capabilities are used on purpose: a bundle that cannot be resolved is exactly the one you want to
+     * see in this list.
      */
-    public static List<Long> findBundleIds(final String message) {
-        if (message == null || message.isEmpty()) {
-            return Collections.emptyList();
+    private static List<PackageExport> findExporters(BundleContext bundleContext, String packageName, Filter requirementFilter) {
+        List<PackageExport> exports = new ArrayList<>();
+
+        for (Bundle bundle : bundleContext.getBundles()) {
+            BundleRevision revision = bundle.adapt(BundleRevision.class);
+            if (revision == null) {
+                continue;
+            }
+
+            for (Capability capability : revision.getCapabilities(PACKAGE_NAMESPACE)) {
+                Map<String, Object> attributes = capability.getAttributes();
+                if (!packageName.equals(attributes.get(PACKAGE_NAMESPACE))) {
+                    continue;
+                }
+
+                exports.add(new PackageExport(bundle, toVersion(attributes.get(CAPABILITY_VERSION_ATTRIBUTE)),
+                        toMatch(requirementFilter, attributes)));
+                break;
+            }
         }
-        Set<Long> bundleIds = new LinkedHashSet<>();
-        Matcher bundlePattern = BUNDLE_PATTERN.matcher(message);
-        while (bundlePattern.find()) {
-            String number = bundlePattern.group(1);
-            bundleIds.add(Long.valueOf(number));
-        }
-        return new ArrayList<>(bundleIds);
+
+        return exports;
     }
 
-
-    private static void printLn(StringBuilder messageBuilder, int indent, String message) {
-        for (int i = 0; i < (indent * 4); i++) {
-            messageBuilder.append(" ");
+    private static RequirementMatch toMatch(Filter requirementFilter, Map<String, Object> attributes) {
+        if (requirementFilter == null) {
+            return RequirementMatch.UNKNOWN;
         }
-        messageBuilder.append(message.trim()).append('\n');
+
+        return requirementFilter.matches(attributes) ? RequirementMatch.MATCHES : RequirementMatch.MISMATCH;
     }
-}
+
+    private static Filter createFilter(String filter) {
+        try {
+            return FrameworkUtil.createFilter(filter);
+        } catch (InvalidSyntaxException e) {
+            // We did not understand the filter the way the framework does - report the exporters without a verdict.
+            return null;
+        }
+    }
+
+    /**
+     * Reads the required package name out of the filter. This is bounded to the filter, which the parser extracted by balancing
+     * parentheses, so it can no longer run past the end of a line and swallow the rest of the message.
+     */
+    private static String findRequiredPackage(String filter) {
+        String assertion = PACKAGE_NAMESPACE + "=";
+
+        int index = filter.indexOf(assertion);
+        if (index < 0) {
+            return null;
+        }
+
+        int start = index + assertion.length();
+        int end = start;
+        while (end < filter.length() && filter.charAt(end) != '(' && filter.charAt(end) != ')') {
+            end++;
+        }
+
+        String packageName = filter.substring(start, end).trim();
+
+        return packageName.isEmpty() ? null : packageName;
+    }
+
+    private static Version toVersion(Object value) {
+        if (value instanceof Version version) {
+            return version;
+        }
+
+        if (value == null) {
+            return Version.emptyVersion;
+        }
+
+        try {
+            return Version.parseVersion(value.toString());
+        } catch (IllegalArgumentException e) {
+            return Version.emptyVersion;
+        }
+    }
+
+    private static String toStateName(Bundle bundle) {
+        return switch (bundle.getState()) {
+            case Bundle.UNINSTALLED -> "UNINSTALLED";
+            case Bundle.INSTALLED -> "INSTALLED";
+            case Bundle.RESOLVED -> "RESOLVED";
+            case Bundle.STARTING -> "STARTING";
+            case Bundle.STOPPING -> "STOPPING";
+            case Bundle.ACTIVE -> "ACTIVE";
+            default -> "UNKNOWN";
+        };
+    }
+}
\ No newline at end of file
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
index d88addc..7e19785 100755
--- a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
@@ -182,7 +182,15 @@
                 }
             } catch (BundleException e) {
                 throw new ResolveError(
-                        "Failed to start " + this + prettyPrintFelixMessage(registry.getBundleContext(), e.getMessage()),
+                        "Failed to start " + this +
+                        prettyPrintFelixMessage(registry.getBundleContext(), e.getMessage()) +
+                        "\n\n" +
+                        BundleResolutionAnalyzer.explainUnresolvedBundles(registry.getBundleContext()) +
+                        "\n\n" +
+                        BundleResolutionAnalyzer.explain(registry.getBundleContext(), bundle) +
+                        "\n\n" +
+                        BundleResolutionAnalyzer.findRootCauses(registry.getBundleContext(), bundle)
+                        ,
                         e);
             }
 
diff --git a/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java b/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
index 801b011..768a522 100644
--- a/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
+++ b/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
@@ -16,21 +16,34 @@
 
 package org.jvnet.hk2.osgiadapter;
 
-import java.util.List;
-
 import org.junit.Test;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.stringContainsInOrder;
+import static org.jvnet.hk2.osgiadapter.FelixPrettyPrinter.findBundleIds;
 import static org.jvnet.hk2.osgiadapter.FelixPrettyPrinter.prettyPrintExceptionMessage;
 
+/**
+ * Expected values are written as text blocks, because the indentation is the thing under test and concatenated literals hide it.
+ */
 public class FelixPrettyPrinterTest {
 
+    private static final String FELIX_SCR_MESSAGE = "  Unable to resolve"
+        + " org.apache.felix.scr [304](R 304.0):"
+        + " missing requirement [org.apache.felix.scr [304](R 304.0)] osgi.wiring.package;"
+        + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))"
+        + " Unresolved requirements: [[org.apache.felix.scr [304](R304.0)] osgi.wiring.package;"
+        + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))]\n"
+        + "at org.apache.felix.framework.Felix.resolveBundleRevision(Felix.java:4398) ";
+
     @Test
     public void testFormatting() {
-        String src = "org.osgi.framework.BundleException:"
+        String source = "org.osgi.framework.BundleException:"
             + " Unable to resolve org.glassfish.main.webservices.connector [207](R 207.0):"
             + " missing requirement [org.glassfish.main.webservices.connector [207](R 207.0)] osgi.wiring.package;"
             + " (&(osgi.wiring.package=jakarta.xml.ws)(version>=3.0.0)(!(version>=4.0.0))) [caused by:"
@@ -39,27 +52,28 @@
             + " (&(osgi.wiring.package=jakarta.xml.bind)(version>=3.0.0)(!(version>=4.0.0)))]"
             + " Unresolved requirements: [[org.glassfish.main.webservices.connector [207](R 207.0)] osgi.wiring.package;"
             + " (&(osgi.wiring.package=jakarta.xml.ws)(version>=3.0.0)(!(version>=4.0.0)))]";
-        String message = FelixPrettyPrinter.prettyPrintExceptionMessage(src);
-        assertThat(message,
-            equalTo(
-                "Unable to resolve\n"
-                    + "    org.glassfish.main.webservices.connector [207]\n"
-                    + "    missing requirement\n"
-                    + "        &(package = jakarta.xml.ws) (version >= 3.0.0) (!(version >= 4.0.0))\n"
-                    + "        caused by:\n"
-                    + "            Unable to resolve\n"
-                    + "                org.glassfish.metro.webservices-api-osgi [236]\n"
-                    + "                missing requirement\n"
-                    + "                    &(package = jakarta.xml.bind) (version >= 3.0.0) (!(version >= 4.0.0)))]\n"));
 
-        assertThat(FelixPrettyPrinter.findBundleIds(message), contains(207L, 236L));
-        assertThat(FelixPrettyPrinter.findBundleIds(src), contains(207L, 236L));
+        String message = prettyPrintExceptionMessage(source);
+        assertThat(message, equalTo("""
+                org.osgi.framework.BundleException:
+                Unable to resolve
+                    org.glassfish.main.webservices.connector [207]
+                    missing requirement
+                        package = jakarta.xml.ws & version >= 3.0.0 & !(version >= 4.0.0)
+                        caused by:
+                            Unable to resolve
+                                org.glassfish.metro.webservices-api-osgi [236]
+                                missing requirement
+                                    package = jakarta.xml.bind & version >= 3.0.0 & !(version >= 4.0.0)
+                """));
+
+        assertThat(findBundleIds(message), contains(207L, 236L));
+        assertThat(findBundleIds(source), contains(207L, 236L));
     }
 
     @Test
     public void testWeld() {
-        String text = prettyPrintExceptionMessage(
-            "org.osgi.framework.BundleException:"
+        String message = prettyPrintExceptionMessage("org.osgi.framework.BundleException:"
             + " Unable to resolve org.glassfish.main.web.weld-integration [41](R 41.0):"
             + " missing requirement [org.glassfish.main.web.weld-integration [41](R 41.0)] osgi.wiring.package;"
             + " (&(osgi.wiring.package=jakarta.faces.application)(version>=4.1.0)(!(version>=5.0.0)))"
@@ -69,27 +83,135 @@
             + " Unresolved requirements: [[org.glassfish.main.web.weld-integration [41](R 41.0)]"
             + " osgi.wiring.package;"
             + " (&(osgi.wiring.package=jakarta.faces.application)(version>=4.1.0)(!(version>=5.0.0)))]");
-        assertThat(text,
+
+        assertThat(message,
             stringContainsInOrder("Unable to resolve", "org.glassfish.main.web.weld-integration", "missing requirement",
                 "jakarta.faces.application", "caused by:", "Unable to resolve", "org.glassfish.jakarta.faces",
-                "missing requirement", "jakarta.enterprise.inject", "(version >= 4.1.0) (!(version >= 5.0.0))"));
-        assertThat(FelixPrettyPrinter.findBundleIds(text), contains(41L, 291L));
+                "missing requirement", "jakarta.enterprise.inject", "version >= 4.1.0 & !(version >= 5.0.0)"));
+        assertThat(findBundleIds(message), contains(41L, 291L));
     }
 
     @Test
     public void testFelix() {
-        String src = FelixPrettyPrinter.prettyPrintExceptionMessage("  Unable to resolve"
-            + " org.apache.felix.scr [304](R 304.0):"
-            + " missing requirement [org.apache.felix.scr [304](R 304.0)] osgi.wiring.package;"
-            + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))"
-            + " Unresolved requirements: [[org.apache.felix.scr [304](R304.0)] osgi.wiring.package;"
-            + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))]\n"
-            + "at org.apache.felix.framework.Felix.resolveBundleRevision(Felix.java:4398) ");
-        String message = FelixPrettyPrinter.prettyPrintExceptionMessage(src);
-        assertThat(message,
-            stringContainsInOrder("Unable to resolve\n", "org.apache.felix.scr [304]\n", "missing requirement\n"));
+        String message = prettyPrintExceptionMessage(FELIX_SCR_MESSAGE);
+        assertThat(message, equalTo("""
+                Unable to resolve
+                    org.apache.felix.scr [304]
+                    missing requirement
+                        package = org.osgi.framework & version >= 1.10.0 & !(version >= 2.0.0)
+                """));
 
-        List<Long> ids = FelixPrettyPrinter.findBundleIds(message);
-        assertThat(ids, contains(304L));
+        assertThat(findBundleIds(message), contains(304L));
     }
-}
+
+    /**
+     * A requirement on an execution environment used to be reported under the wrong module, because the parser searched forward for a
+     * package requirement without staying inside the clause it was describing.
+     */
+    @Test
+    public void testExecutionEnvironment() {
+        String message = prettyPrintExceptionMessage("org.osgi.framework.BundleException:"
+            + " Unable to resolve org.glassfish.main.core [12](R 12.0):"
+            + " missing requirement [org.glassfish.main.core [12](R 12.0)] osgi.ee;"
+            + " (&(osgi.ee=JavaSE)(version=21))");
+
+        assertThat(message, equalTo("""
+                org.osgi.framework.BundleException:
+                Unable to resolve
+                    org.glassfish.main.core [12]
+                    missing requirement
+                        osgi.ee = JavaSE & version = 21
+                """));
+    }
+
+    @Test
+    public void testFragmentHost() {
+        String message = prettyPrintExceptionMessage("Unable to resolve org.hibernate.validator.cdi [263](R 263.0):"
+            + " missing requirement [org.hibernate.validator.cdi [263](R 263.0)] osgi.wiring.host;"
+            + " (&(osgi.wiring.host=org.hibernate.validator)(bundle-version>=9.1.0))");
+
+        assertThat(message, equalTo("""
+                Unable to resolve
+                    org.hibernate.validator.cdi [263]
+                    missing requirement
+                        host = org.hibernate.validator & bundle-version >= 9.1.0
+                """));
+    }
+
+    @Test
+    public void testRequirementWithoutVersionRange() {
+        String message = prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.foo [7](R 7.0):"
+            + " missing requirement [org.glassfish.main.foo [7](R 7.0)] osgi.wiring.package;"
+            + " (osgi.wiring.package=jakarta.validation)");
+
+        assertThat(message, equalTo("""
+                Unable to resolve
+                    org.glassfish.main.foo [7]
+                    missing requirement
+                        package = jakarta.validation
+                """));
+    }
+
+    /**
+     * Nesting inside a filter keeps its parentheses, so that the grouping the resolver meant is still visible once the operators move
+     * between their operands.
+     */
+    @Test
+    public void testNestedDisjunctionKeepsItsGrouping() {
+        String message = prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.foo [7](R 7.0):"
+            + " missing requirement [org.glassfish.main.foo [7](R 7.0)] osgi.wiring.package;"
+            + " (&(osgi.wiring.package=org.bar)(|(version=1.0.0)(version=2.0.0)))");
+
+        assertThat(message, containsString("package = org.bar & (version = 1.0.0 | version = 2.0.0)"));
+    }
+
+    /**
+     * A message the parser does not fully understand must never be replaced by a shorter one that drops what it could not read.
+     */
+    @Test
+    public void testUnrecognisedMessageKeepsTheOriginal() {
+        String source = "Unable to resolve org.glassfish.main.foo [7](R 7.0): something the resolver has never said before";
+
+        assertThat(prettyPrintExceptionMessage(source), containsString(source));
+    }
+
+    /**
+     * Formatting an already formatted message is a mistake, but it still may not lose anything.
+     */
+    @Test
+    public void testAlreadyFormattedMessageIsNotDestroyed() {
+        String once = prettyPrintExceptionMessage(FELIX_SCR_MESSAGE);
+
+        assertThat(prettyPrintExceptionMessage(once), containsString(once));
+    }
+
+    /**
+     * Two failures are only nested when the resolver said one caused the other. Without a "caused by" they are siblings.
+     */
+    @Test
+    public void testSiblingFailuresAreNotNested() {
+        String message = prettyPrintExceptionMessage("Unable to resolve A [1](R 1.0):"
+            + " missing requirement [A [1](R 1.0)] osgi.wiring.package; (osgi.wiring.package=p.one)"
+            + " Unable to resolve B [2](R 2.0):"
+            + " missing requirement [B [2](R 2.0)] osgi.wiring.package; (osgi.wiring.package=p.two)");
+
+        assertThat(message, equalTo("""
+                Unable to resolve
+                    A [1]
+                    missing requirement
+                        package = p.one
+                Unable to resolve
+                    B [2]
+                    missing requirement
+                        package = p.two
+                """));
+    }
+
+    @Test
+    public void testNullAndEmptyMessage() {
+        assertThat(prettyPrintExceptionMessage(null), nullValue());
+        assertThat(prettyPrintExceptionMessage(""), equalTo(""));
+        assertThat(findBundleIds(null), empty());
+        assertThat(findBundleIds(""), empty());
+    }
+}
\ No newline at end of file