Interaction between depenency substitution and forced version (likely a bug)

If you run gradle dependencies for the following build:

apply plugin: 'java'
configurations.all {
    resolutionStrategy {
        force 'org.codehaus.groovy:groovy-all:2.4.5','org.codehaus.groovy:groovy:2.4.5'
        eachDependency {
            if (requested.name == 'groovy-all') {
                useTarget group: requested.group, name: 'groovy', version: requested.version
            }
        }
    }
}

dependencies {
    compile 'org.spockframework:spock-core:1.0-groovy-2.4'
//    compile 'org.codehaus.groovy:groovy:2.4.1'
}

You would see:

testRuntime - Runtime classpath for source set 'test'.
\--- org.spockframework:spock-core:1.0-groovy-2.4
     +--- org.codehaus.groovy:groovy-all:2.4.1 -> org.codehaus.groovy:groovy:2.4.1
     \--- junit:junit:4.12
          \--- org.hamcrest:hamcrest-core:1.3

Notice how the groovy dependency is not forced to 2.4.5

If we remove the dependency substitution rule, it would work.
If we uncomment the explicit dependency below it will also work.

I think this is what you’re seeing…

The forced versions are applied to the dependency graph (if groovy or groovy-all is seen, the target version is set to 2.4.5).
The dependency substitution rule is applied to the dependency graph (if groovy-all is seen, the target version is set to 2.4.1). The dependency substitution rule sets the target version to the requested version, which is 2.4.1.

When you remove the dependency substitution rule, it works because you’re no longer setting the target to the requested version.

When you add an explicit dependency on groovy, it works because the dependency substitution rule doesn’t change it and you’re forcing the version to be 2.4.5. Dependency conflict resolution kicks in at this point (we have both 2.4.1 and 2.4.5 in the graph, so we choose the latest).

You can see this with gradle dependencyInsight --dependency groovy for both cases or printing out requested and target in the dependency substitution rule.

Thanks for explaining, yet would you agree that this behavior is a bug, or is there a reason to keep it this way?

How about adding a warning in the docs, or perhaps reapplying the forced version rules to the substituted modules?

Reapplying the forced version rules might not make sense, since the eachDependency dependency resolve rules are supposed to allow you to do very specific things just before we resolve dependencies.

You could fix this by changing your rule to:

        eachDependency {
            if (requested.name == 'groovy-all') {
                useTarget group: requested.group, name: 'groovy', version: target.version
            }
        }

Using requested.version overrides the force since it’s like you’re forcing the version back to the requested version (for groovy).

I think it could be made more clear the order and interaction between these different rules.

In practice the “don’t use groovy-all” rule comes from a plugin enforcing enterprise-wide conventions, while the “always use 2.4.5” is coming from the particular application build.

I am trying not to introduce too many magical properties, that one would need to know. In this case we are using the core Gradle facilities and the intent is quite unambiguous. Given that I am passing-through the requested.version, I would expect the forced version to be applied at some point before or after the resolution.

If I am to describe the current behavior from external view, it would come out as:

When using force(), we guarantee that the artifacts in question will always be resolved to the specified version, UNLESS they are transient dependencies AND are matching an existing dependency substitution rule (i.e. specified in resolutionStrategy.eachDependency { useTarget(...) })

Not quite what I’d call intuitive or easy to remember…

I assume you mean “transitive dependencies” (not transient)? I don’t think that has any direct bearing here.

The rule is,

When using force(), we guarantee that the modules will always be resolved to the specified version, unless a dependency resolve rule provides a different target version.

In your example, the dependency resolve rule overrides the target version (from 2.4.5 back to 2.4.1). The requested version is the version from the POM/Ivy metadata or Dependency.

Given your build script (with a direct dependency on groovy), we have these dependencies before applying any force/resolve rules:

  • groovy-all:2.4.1 ← spock:1.0
  • groovy:2.4.1

We apply force() rules:

  • groovy-all:2.4.5 ← spock:1.0
  • groovy:2.4.5

We apply the resolve rules:

  • groovy:2.4.1 ← spock:1.0
  • groovy:2.4.5

We then do conflict resolution:

  • groovy:2.4.5* ← spock:1.0
  • groovy:2.4.5

I think it all comes down to this:

configurations.all {
    resolutionStrategy {
        force 'org.codehaus.groovy:groovy:2.4.5'
        eachDependency {
            if (requested.name == 'groovy') {
                useTarget group: requested.group, name: 'groovy', version: "2.4.1"
            }
        }
    }
}

Which version do you get? 2.4.1

Thanks Sterling, I understand what is going on now and what I need is relatively straightforward to achieve using the existing API. In case somebody has the same problem, I changed my plugin to:

        //  [some code snipped...]
        project.getConfigurations().all(conf -> {
            ResolutionStrategy resolutionStrategy = conf.getResolutionStrategy();
            resolutionStrategy.cacheChangingModulesFor((int) SNAPSHOTS_CACHE_TIMEOUT, SECONDS);
            resolutionStrategy.eachDependency(dep -> {
                ModuleVersionSelector requested = dep.getRequested();
                // always use 'groovy' instead of 'groovy-all' to allow better control
                if ("groovy-all".equals(requested.getName())) {
                    String targetVersion = resolveTargetVersion("groovy", "org.codehaus.groovy", requested.getVersion(), resolutionStrategy);
                    dep.useTarget("org.codehaus.groovy:groovy:" + targetVersion);
                }
            });
        });
    }

    private String resolveTargetVersion(String artifactId, String artifactGroup, String requestedVersion, ResolutionStrategy resolutionStrategy) {
        List<String> forcedVersions = resolutionStrategy.getForcedModules().stream()
                .filter(it -> artifactId.equals(it.getName()) && artifactGroup.equals(it.getGroup()))
                .map(ModuleVersionSelector::getVersion)
                .distinct()
                .collect(Collectors.toList());

        resolutionStrategy.getForcedModules().forEach(System.out::println);

        if (forcedVersions.size()>1) {
            throw new IllegalStateException("Too many forced versions for " + artifactGroup + ":" + artifactId+ " - " + forcedVersions);
        }

        return forcedVersions.isEmpty() ? requestedVersion : forcedVersions.get(0);
    }