Repository: pantsbuild/pants

Issue:
Resolve `jvm_artifacts` in `strict` mode
By default `coursier` (and most other resolvers) doesn't guarantee that a root requirement of the resolve ends up with the precisely specified version. This means that the packages detected as exported by a `jvm_artifact` might not match the resolved artifact if a different version is fetched:
```
$ cs resolve org.junit.jupiter:junit-jupiter-engine:5.7.2 org.junit.platform:junit-platform-commons:1.0.0
org.apiguardian:apiguardian-api:1.1.0:default
org.junit.jupiter:junit-jupiter-api:5.7.2:default
org.junit.jupiter:junit-jupiter-engine:5.7.2:default
org.junit.platform:junit-platform-commons:1.7.2:default
org.junit.platform:junit-platform-engine:1.7.2:default
org.opentest4j:opentest4j:1.2.0:default
```

But `coursier` optionally supports enforcing that exact versions are chosen (via `--strict-include`): if we are able to require that mode, then we can enforce that all directly declared dependencies get their exact versions:
```
$ cs resolve org.junit.jupiter:junit-jupiter-engine:5.7.2 org.junit.platform:junit-platform-commons:1.0.0 --strict-include org.junit.platform:junit-platform-commons
Resolution error: Unsatisfied rule Strict(org.junit.platform:junit-platform-commons): Found evicted dependencies:

org.junit.platform:junit-platform-commons:1.7.2 (1.0.0 wanted)
└─ org.junit.platform:junit-platform-commons:1.0.0 wants org.junit.platform:junit-platform-commons:1.0.0
```

----

In Pants v1, whether a particular artifact was strict was controlled by a flag: `force=True`, but the default was `force=False`: over time it became clear that `force=True` would have been a much better default. We can start with strict here, and only add an option to disable that if it proves to be necessary.


Discussion:

Resolving patch:
diff --git a/src/python/pants/jvm/resolve/coursier_fetch.py b/src/python/pants/jvm/resolve/coursier_fetch.py
--- a/src/python/pants/jvm/resolve/coursier_fetch.py
+++ b/src/python/pants/jvm/resolve/coursier_fetch.py
@@ -47,6 +47,9 @@ class Coordinate:
     artifact: str
     version: str
     packaging: str = "jar"
+    # True to enforce that the exact declared version of a coordinate is fetched, rather than
+    # allowing dependency resolution to adjust the version when conflicts occur.
+    strict: bool = True
 
     @staticmethod
     def from_json_dict(data: dict) -> Coordinate:
@@ -75,8 +78,10 @@ def from_coord_str(cls, s: str) -> Coordinate:
             packaging=parts[3] if len(parts) == 4 else "jar",
         )
 
-    def to_coord_str(self) -> str:
-        return f"{self.group}:{self.artifact}:{self.version}"
+    def to_coord_str(self, versioned: bool = True) -> str:
+        if versioned:
+            return f"{self.group}:{self.artifact}:{self.version}"
+        return f"{self.group}:{self.artifact}"
 
 
 class Coordinates(DeduplicatedCollection[Coordinate]):
@@ -239,7 +244,15 @@ async def coursier_resolve_lockfile(
         ProcessResult,
         Process(
             argv=coursier.args(
-                [coursier_report_file_name, *(req.to_coord_str() for req in artifact_requirements)],
+                [
+                    coursier_report_file_name,
+                    *(req.to_coord_str() for req in artifact_requirements),
+                    *(
+                        f"--strict-include={req.to_coord_str(versioned=False)}"
+                        for req in artifact_requirements
+                        if req.strict
+                    ),
+                ],
                 wrapper=[bash.path, coursier.wrapper_script],
             ),
             input_digest=coursier.digest,


Test patch:
diff --git a/src/python/pants/jvm/resolve/coursier_fetch_integration_test.py b/src/python/pants/jvm/resolve/coursier_fetch_integration_test.py
--- a/src/python/pants/jvm/resolve/coursier_fetch_integration_test.py
+++ b/src/python/pants/jvm/resolve/coursier_fetch_integration_test.py
@@ -9,6 +9,7 @@
 from pants.core.util_rules.external_tool import rules as external_tool_rules
 from pants.engine.fs import FileDigest
 from pants.engine.internals.scheduler import ExecutionError
+from pants.engine.process import ProcessExecutionFailure
 from pants.jvm.resolve.coursier_fetch import (
     ArtifactRequirements,
     Coordinate,
@@ -23,7 +24,7 @@
 from pants.jvm.testutil import maybe_skip_jdk_test
 from pants.jvm.util_rules import ExtractFileDigest
 from pants.jvm.util_rules import rules as util_rules
-from pants.testutil.rule_runner import PYTHON_BOOTSTRAP_ENV, QueryRule, RuleRunner
+from pants.testutil.rule_runner import PYTHON_BOOTSTRAP_ENV, QueryRule, RuleRunner, engine_error
 
 HAMCREST_COORD = Coordinate(
     group="org.hamcrest",
@@ -151,6 +152,24 @@ def test_resolve_with_inexact_coord(rule_runner: RuleRunner) -> None:
     )
 
 
+@maybe_skip_jdk_test
+def test_resolve_conflicting(rule_runner: RuleRunner) -> None:
+    with engine_error(
+        ProcessExecutionFailure, contains="Resolution error: Unsatisfied rule Strict(junit:junit)"
+    ):
+        rule_runner.request(
+            CoursierResolvedLockfile,
+            [
+                ArtifactRequirements(
+                    [
+                        Coordinate(group="junit", artifact="junit", version="4.8.1"),
+                        Coordinate(group="junit", artifact="junit", version="4.8.2"),
+                    ]
+                ),
+            ],
+        )
+
+
 @maybe_skip_jdk_test
 def test_fetch_one_coord_with_no_deps(rule_runner: RuleRunner) -> None: