Challenge

Resolve six conflicting trees

25 minintermediate115 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • Nearest-wins and highest-wins give different answers for the same tree
  • Version comparison is numeric per component, never lexicographic
  • dependencyManagement overrides depth entirely, which is why it is the right pinning tool
  • Some conflicts have no version that satisfies everyone, and saying so is the answer

Starter

Starter.java
import java.util.*;

/**
 * Challenge: resolve six dependency trees two different ways.
 *
 * You are implementing the part of a build tool that answers "which version
 * ends up on the classpath". Two rules, six trees, and one tree that has no
 * correct answer at all.
 */
public class Starter {

    /** One reachable version of one artifact: how deep, and through what path. */
    record Dep(String artifact, String version, int depth, String via) {}

    /** A tree is just the set of (artifact, version, depth) edges it produces. */
    record Tree(String name, List<Dep> edges, String note) {}

    static final List<Tree> TREES = List.of(

        // 1. The textbook case: newer version is further away.
        new Tree("basic", List.of(
            new Dep("commons-lang3", "3.4",  2, "report-service"),
            new Dep("commons-lang3", "3.12", 3, "audit-client -> http-toolkit")),
            "which rule downgrades you?"),

        // 2. Same depth. Now what decides?
        new Tree("tie", List.of(
            new Dep("guava", "31.0", 2, "search-client   (declared first)"),
            new Dep("guava", "32.1", 2, "billing-client  (declared second)")),
            "Maven breaks ties by declaration order"),

        // 3. Lexicographic ordering is wrong here.
        new Tree("double-digit", List.of(
            new Dep("jackson-databind", "2.9.0",  2, "legacy-api"),
            new Dep("jackson-databind", "2.17.1", 3, "spring-boot-starter-web")),
            "sort these as strings and see what you get"),

        // 4. Three paths, three versions.
        new Tree("three-way", List.of(
            new Dep("slf4j-api", "1.7.36", 4, "old-batch -> shared-core -> logging-shim"),
            new Dep("slf4j-api", "2.0.7",  2, "http-client"),
            new Dep("slf4j-api", "2.0.13", 3, "metrics-agent -> exporter")),
            "the rules disagree, and neither picks the middle one"),

        // 5. The root declares it directly.
        new Tree("root-pinned", List.of(
            new Dep("commons-io", "2.11.0", 1, "declared in this pom"),
            new Dep("commons-io", "2.15.1", 2, "storage-client")),
            "depth 1 beats everything under nearest-wins"),

        // 6. No version satisfies everyone.
        new Tree("unsatisfiable", List.of(
            new Dep("crypto-utils", "3.9",  2, "signing-service  (calls sign(byte[]) — REMOVED in 3.10)"),
            new Dep("crypto-utils", "3.12", 2, "token-service    (calls verify(Key) — ADDED in 3.12)")),
            "read the callers, not just the numbers")
    );

    public static void main(String[] args) {

        // TODO 1: write compareVersions(String, String) comparing numeric
        // components, so 1.10.0 > 1.9.0. Prove it against String.compareTo
        // before you use it anywhere.

        // TODO 2: implement nearestWins — the smallest depth. Decide what
        // happens on a tie and say which tree that matters for.

        // TODO 3: implement highestWins — the greatest version, ignoring depth.

        // TODO 4: run all six trees through both rules and print a table:
        // tree name, Maven's answer, Gradle's answer, and whether they agree.

        // TODO 5: for every tree where they disagree, say in one line what a
        // developer would experience — specifically, who breaks and when.

        // TODO 6: model dependencyManagement. It is not another candidate; it
        // replaces the outcome regardless of depth. Add:
        //
        //   Map<String, String> managed = Map.of("commons-lang3", "3.12");
        //
        // and make both rules consult it first. Re-run tree 1.

        // TODO 7: tree 6 has no version that works. Detect it — the notes tell
        // you which methods each caller needs. Report it as unsatisfiable
        // rather than picking one, and list the three real options.

        // TODO 8: which single tree would you most want the build to FAIL on
        // rather than resolve silently? Say why, and name the Maven and Gradle
        // settings that would do it.
    }
}

Run it locally:

cd exercises/java/dependencies/transitive-conflicts/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Implement both rules before looking at any tree. Then run every tree through both and compare — the disagreements are the interesting cases.

  2. Hint 2

    Sort 1.9.0, 1.10.0 and 1.10.2 with String.compareTo, then with your comparator. One of the six trees is designed around this.

  3. Hint 3

    dependencyManagement is not another candidate in the list. It replaces the choice, at any depth. Model it as a lookup consulted first.

  4. Hint 4

    One tree has no winner: a caller needs a method added in 3.12 and another needs one removed in 3.10. Say so and name the three options.

Done when

  • Both resolution rules implemented and run over all six trees
  • A table showing which trees the two rules disagree on
  • A numeric version comparator, with a test that lexicographic ordering fails
  • dependencyManagement modelled as an override that ignores depth
  • The unsatisfiable tree identified, with the options named rather than a version chosen

← Back to Two libraries need different versions of the same dependency. What happens?