Encapsulate bzlmod dependency layering (#1618)
diff --git a/MODULE.bazel b/MODULE.bazel
index f9dd02b..22e50a9 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -204,6 +204,18 @@
     neverlink = "on",
 )
 dev_maven.install(
+    name = "duplicate_version_error",
+    artifacts = [
+        "com.fasterxml.jackson.core:jackson-annotations:2.10.1",
+        "com.fasterxml.jackson.core:jackson-annotations:2.12.1",
+    ],
+    duplicate_version_warning = "error",
+    repositories = [
+        "https://repo1.maven.org/maven2",
+        "https://maven.google.com",
+    ],
+)
+dev_maven.install(
     name = "duplicate_version_warning",
     artifacts = [
         "com.fasterxml.jackson.core:jackson-annotations:2.10.1",
@@ -1054,6 +1066,23 @@
 )
 
 dev_maven.install(
+    name = "coursier_layering",
+    artifacts = [
+        "com.google.code.findbugs:jsr305:3.0.1",
+    ],
+    known_contributing_modules = [
+        "coursier_higher_layer",
+    ],
+    resolver = "coursier",
+)
+
+bazel_dep(name = "coursier_higher_layer", version = "0.0.0", dev_dependency = True)
+local_path_override(
+    module_name = "coursier_higher_layer",
+    path = "tests/integration/coursier_higher_layer",
+)
+
+dev_maven.install(
     name = "root_module_can_override",
     artifacts = ["com.squareup:javapoet:1.11.1"],
     known_contributing_modules = [
@@ -1087,6 +1116,8 @@
     dev_maven,
     "amend_artifacts",
     "bom_only_pinning",
+    "coursier_layering",
+    "duplicate_version_error",
     "duplicate_version_warning",
     "duplicate_version_warning_same_version",
     "exclusion_testing",
diff --git a/docs/bzlmod.md b/docs/bzlmod.md
index b07de4a..edce4bc 100644
--- a/docs/bzlmod.md
+++ b/docs/bzlmod.md
@@ -172,43 +172,168 @@
 
 ## Module dependency layering
 
-In order to allow modules to collaborate on required dependencies, the `bzlmod` extension will
-collect the artifacts from all tags with the same `name` attribute together before performing a
-dependency resolution. You'll know this is happening because a message will be printed to inform
-you which modules are contributing to which namespace:
+The extension collects declarations from all tags with the same `name` before resolving them. Each
+name is an independent Maven repository namespace. Declarations in one namespace never affect
+another namespace.
+
+The root module and its dependencies have different roles during layering. The root contributes
+the declarations that belong to the current Bazel project. Every other module is a non-root
+contributor. Coordinates are conceptually matched by `group:artifact:packaging:classifier`,
+meaning that a classified JAR layers independently of its unclassified JAR.
+
+When performing [duplicate coordinate checks](#diagnostics), the declarations are keyed by
+`group:artifact:classifier`, but not packaging. Packaging-distinct declarations at different
+versions can therefore warn or fail even though the extension layers them independently. It also
+continues to check multiple root declarations. Ordinary cross-module conflicts with the same
+layering key no longer reach this check.
+
+### Version precedence
+
+For conflicts between modules, layering selects one complete declaration for each coordinate. The
+selected declaration supplies its exclusions, `neverlink`, `testonly`, `force_version`, packaging,
+classifier, and other fields. Fields from discarded declarations are not merged into it. Layering
+does not deduplicate within the root module, so repeated root declarations for one coordinate reach
+the existing repository-level duplicate check, which warns or fails according to
+`duplicate_version_warning`. Forcing is the exception: if any module, the root included, sets
+`force_version` on the same coordinate at two different versions, layering will fail with an
+error message.
+
+The surviving declaration is chosen by these rules:
+
+1. A forced version in the root module always wins.
+2. Otherwise, a forced declaration beats any unforced one, whatever the versions.
+3. Otherwise, the highest version wins, regardless of which module declared it.
+
+On ties and conflicts:
+
+- Two non-root modules that force different versions is an error and fails before resolution. The root can settle it by forcing the version itself.
+- On a tie (equal versions, or the same forced version from more than one module) the first module's declaration is kept; the root counts as first.
+- A non-root artifact marked `testonly` is dropped.
+
+Be aware that non-default packaging and classifiers remain independent of each other and of the
+plain versioned coordinate. This may lead to some surprises when resolution is complete.
+
+"Highest" uses the Maven `ComparableVersion` ordering implemented by
+`private/rules/maven_version.bzl`, not lexical string ordering.
+
+`version_conflict_policy = "pinned"` changes this interaction. For the Gradle and Maven resolvers,
+root artifacts are marked as `force_version` before layering. The duplicate-force check applies to
+declared forces before this policy is applied. Maven then marks every versioned root declaration.
+Gradle first selects one version for each root `group:artifact`: an unclassified declaration takes
+precedence over classified declarations, and Maven `ComparableVersion` order selects among
+declarations with the same classification status. Every root declaration for that module at the
+selected version is then marked forced, including classified declarations. The root consequently
+wins because it now forces the coordinate. For Coursier, layering is unchanged and the one
+surviving direct version is later passed as a `--force-version` argument. A higher non-root
+version can therefore displace the root under Coursier and then be pinned.
+
+The `force_version` flag can be set by an `artifact` tag, an `amend_artifact` tag, or a regular
+artifact read by `from_toml`. Coordinates in `install.artifacts` cannot carry the flag. BOMs use the
+same extension-layer precedence rules as artifacts.
+
+### Contributors and configuration
+
+When the root and other modules contribute artifacts to the same namespace, the extension prints a
+message such as:
 
 `The maven repository 'multiple_lock_files' has contributions from multiple bzlmod modules, and will be resolved together: ["bzlmod_lock_files", "rules_jvm_external"]`
 
-In the root module, if this is expected and known, you can disable this warning by adding
-the list of modules to the `known_contributing_modules` attribute of the `install` tag. The entry
-to add will be printed for you as part of the warning. Once you set a value for `known_contributing_modules` then only those modules will be allowed to contribute dependencies.
+If those contributions are expected, set `known_contributing_modules` on the root `install` tag.
+The warning includes the value to add. Once this attribute is non-empty, only listed modules may
+contribute artifacts or BOMs to that namespace. A module that contributes only BOMs triggers the
+same contribution warning and can be acknowledged through the same attribute.
 
-The default name used is `maven`. Modules that are expected to be included via a `bazel_dep` should
-avoid using the default name, and should always set their own (eg. `rules_jvm_external` uses
-`rules_jvm_external_deps` for its own dependencies) The exception to this is where a module provides
-functionality that would otherwise be obtained using a maven dependency.
+After dependencies are layered, scalar `install` attributes from the root module take precedence.
+List attributes are combined root-first, while preserving their existing deduplication or
+concatenation behaviour.
 
-Put another way, only projects that are only ever going to be used as root modules should use the
-default name.
+The default namespace is `maven`. A module intended for use through `bazel_dep` should normally use
+its own name, such as the `rules_jvm_external_deps` namespace used by this project. The default is
+appropriate when a module deliberately contributes functionality that would otherwise be supplied
+as a Maven dependency, or when the project is only used as the root module.
 
-The message is printed so that should you need to understand why a particular dependency or
-transitive dependency is at an unexpected version you'll have the information you need to diagnose
-the problem.
+### <a id="diagnostics"></a>Diagnostics
 
-When dependencies are layered in this way, you may see a warning similar to:
+Layering keeps the following diagnostics so that unexpected versions can be traced to their
+contributing module. Each entry shows the message a user sees and how to resolve it. Several are
+governed by [`duplicate_version_warning`](bzlmod-api.md#maven.install-duplicate_version_warning),
+which is `"error"` to fail, `"warn"` (the default) to print and continue, or `"none"` to stay
+silent.
+
+#### Which modules are contributing to this repository?
+
+An unacknowledged non-root module contributing artifacts or BOMs always prints the contribution
+warning:
 
 ```
-"WARNING: The following maven modules appear in multiple sub-modules with potentially different versions. Consider adding one of these to your root module to ensure consistent versions:
-    com.google.guava:guava (31.1-jre, 33.2.1-jre)
+The maven repository 'my-project' has contributions from multiple bzlmod modules, and will be resolved together: ["my-project", "some-other-module"]
 ```
 
-The resolver will use the highest version artifact from the root and sub-modules. If the root version is not the highest you will see a warning during repinning similar to:
+**Remedy:** if the contributions are expected, set `known_contributing_modules` on the root
+`install` tag to the module names in the message; otherwise remove the contributing module. When
+`known_contributing_modules` instead excludes a contributor, an `INFO` message is printed when
+`RJE_VERBOSE` is set.
+
+#### Why is my forced version rejected?
+
+One module forcing the same coordinate at two different versions fails:
+
+```
+Module 'my_module' forces dependency 'com.google.guava:guava' at different versions: 31.1-jre and 33.0.0-jre.
+```
+
+**Remedy:** keep a single version for the coordinate within that module.
+
+Non-root modules forcing different versions of a coordinate that the root does not force fails with:
+
+```
+Conflicting forced versions for dependency 'com.google.guava:guava': module_a wants 31.1-jre, module_b wants 33.0.0-jre. Add an `artifact` tag to the root module at the version you want and set `force_version = True`.
+```
+
+**Remedy:** add an `artifact` tag to the root module at the version you want and set
+`force_version = True` on it.
+
+#### Which version will be selected?
+
+When layering selects a version different from the root version, the version-selection warning is:
 
 ```
 WARNING: For dependency 'com.google.protobuf:protobuf-java' the root @maven repo wants version 3.25.5, but got 4.27.2 from the bazel_worker_java bazel dep. Please update the version in your MODULE.bazel or set `force_version = True`.
 ```
 
-You can either update the version in the root module to the highest version or set `force_version = True` in the root module to ensure that version will be the one used in the dependency resolution.
+`duplicate_version_warning` controls whether this warns, fails, or stays silent.
+
+**Remedy:** update the version in the root module to the highest version, or set
+`force_version = True` in the root module to ensure that version is the one used
+in dependency resolution.
+
+You only see this when the version that ends up being used differs from the one declared in your
+root module. For example, a `bazel_dep` may pull in a higher version of a dependency you also
+declare in the root. If the resolved version already matches your root declaration, there is
+nothing to act on and no warning is printed. Coordinates that only a `bazel_dep` declares (and
+your root does not) do not produce this warning either; they are covered by the contribution
+warning above instead.
+
+#### Which versions are reaching the repository?
+
+When more than one version of the same dependency makes it into the repository, whether declared
+twice in one module or contributed by several modules, the message is:
+
+```
+Found duplicate artifact versions
+    com.google.guava:guava has multiple versions 31.1-jre, 33.0.0-jre
+Please remove duplicate artifacts from the artifact list so you do not get unexpected artifact versions
+```
+
+`duplicate_version_warning` controls whether this warns, fails, or stays silent. **Remedy:** remove
+duplicate artifacts from the artifact list.
+
+A non-root-only coordinate is reported as an `INFO` message when a repin variable and `RJE_VERBOSE`
+are both set:
+
+```
+INFO: The @maven repo is getting the additional artifact com.google.guava:guava:33.0.0-jre from the module_a bazel dep.
+```
 
 ## Known issues
 
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl
index d64709a..503e6c2 100644
--- a/private/extensions/maven.bzl
+++ b/private/extensions/maven.bzl
@@ -9,9 +9,15 @@
     "escape",
     "strip_packaging_and_classifier_and_version",
 )
-load("//private/lib:coordinates.bzl", "to_external_form", "to_key", "unpack_coordinates")
+load("//private/lib:coordinates.bzl", "to_external_form", "unpack_coordinates")
+load(
+    "//private/lib:layering.bzl",
+    "DEFAULT_NAME",
+    "layer_maven_namespace",
+    "remove_empty_fields",
+    "should_print_diagnostic",
+)
 load("//private/rules:coursier.bzl", "DEFAULT_AAR_IMPORT_LABEL", "coursier_fetch", "pinned_coursier_fetch")
-load("//private/rules:maven_version.bzl", "compare_maven_versions")
 load("//private/rules:unpinned_maven_pin_command_alias.bzl", "unpinned_maven_pin_command_alias")
 load("//private/rules:v1_lock_file.bzl", "v1_lock_file")
 load("//private/rules:v3_lock_file.bzl", "v2_lock_file", "v3_lock_file")
@@ -21,8 +27,6 @@
     "https://repo1.maven.org/maven2",
 ]
 
-DEFAULT_NAME = "maven"
-
 _DEFAULT_RESOLVER = "coursier"
 
 artifact = tag_class(
@@ -77,7 +81,7 @@
         "additional_netrc_lines": attr.string_list(doc = "Additional lines prepended to the netrc file used by `http_file` (with `maven_install_json` only).", default = []),
         "use_credentials_from_home_netrc_file": attr.bool(doc = "Whether to pass machine login credentials from the ~/.netrc file to coursier.", default = False),
         "duplicate_version_warning": attr.string(
-            doc = """What to do if there are duplicate artifacts
+            doc = """What to do if layering selects a non-root version instead of the root version, or if the root module declares multiple versions of the same coordinate
 
             If "error", then print a message and fail the build.
             If "warn", then print a warning and continue.
@@ -181,24 +185,6 @@
             to_return.append(exclusion)
     return to_return
 
-def _warn_if_multiple_contributing_modules(repo, repo_name, non_root_bazel_dep_to_artifacts):
-    known_contributing_modules = repo.get("known_contributing_modules", sets.make())
-    contributing_module_names = non_root_bazel_dep_to_artifacts.keys()
-    new_contributing_modules = sets.difference(sets.make(contributing_module_names), known_contributing_modules)
-    if sets.length(new_contributing_modules) > 0:
-        print("The maven repository '%s' has contributions from multiple bzlmod modules, and will be resolved together: %s." % (
-                  repo_name,
-                  sorted(contributing_module_names),
-              ) + "\nSee https://github.com/bazel-contrib/rules_jvm_external/blob/master/docs/bzlmod.md#module-dependency-layering" +
-              " for more information. \n" +
-              " To suppress this warning review the contributions from the other modules and add the following attribute" +
-              " in the root MODULE.bazel file: \n" +
-              "maven.install(\n" +
-              ("  name = \"{0}\"\n".format(repo_name) if repo_name != DEFAULT_NAME else "") +
-              "  known_contributing_modules = {0},\n".format(sorted(contributing_module_names)) +
-              "  ...\n" +
-              ")")
-
 def _generate_compat_repos(name, existing_compat_repos, artifacts):
     seen = []
 
@@ -217,68 +203,6 @@
 
     return seen
 
-def _deduplicate_non_root_artifacts(bazel_dep_to_non_root_artifacts, return_only_artifacts = False):
-    coordinate_to_artifact = {}
-    for bazel_dep_name in bazel_dep_to_non_root_artifacts:
-        for artifact in bazel_dep_to_non_root_artifacts.get(bazel_dep_name, []):
-            if not getattr(artifact, "testonly", False):
-                artifact_key = to_key(artifact)
-
-                # prioritize highest version
-                if artifact_key in coordinate_to_artifact:
-                    _bazel_dep_name, current_artifact = coordinate_to_artifact[artifact_key]
-                    if compare_maven_versions(current_artifact.version, artifact.version) == -1:
-                        coordinate_to_artifact[artifact_key] = (bazel_dep_name, artifact)
-                else:
-                    coordinate_to_artifact[artifact_key] = (bazel_dep_name, artifact)
-
-    if return_only_artifacts:
-        return [v[1] for v in coordinate_to_artifact.values()]
-    else:
-        return coordinate_to_artifact
-
-# Each bzlmod module may contribute jars to different rules_jvm_external maven repo namespaces.
-# We emit a warning to the user if a module overrides an artifact version in the root maven repo.
-#
-# This can be typical for the default @maven namespace, if a bzlmod dependency
-# wishes to contribute to the users' jars.
-def _deduplicate_artifacts_with_root_priority(name, root_artifacts, bazel_dep_to_non_root_artifacts, repin_env_var, rje_verbose_env_var):
-    """Deduplicate artifacts, giving priority to root module artifacts with force_version set."""
-    non_root_coordinate_to_artifact = _deduplicate_non_root_artifacts(bazel_dep_to_non_root_artifacts)
-
-    duplicate_artifact_warning = ""
-    filtered_non_root_artifacts = []
-    for root_artifact in root_artifacts:
-        artifact_key = to_key(root_artifact)
-        if artifact_key in non_root_coordinate_to_artifact:
-            bazel_dep_name, non_root_artifact = non_root_coordinate_to_artifact.pop(artifact_key)
-            if not getattr(root_artifact, "force_version", False):
-                # prioritize highest version
-                if compare_maven_versions(root_artifact.version, non_root_artifact.version) == -1:
-                    filtered_non_root_artifacts.append(non_root_artifact)
-                    duplicate_artifact_warning = duplicate_artifact_warning + (
-                        "\nWARNING: For dependency '%s:%s' the root @%s repo wants version %s, " % (root_artifact.group, root_artifact.artifact, name, root_artifact.version) +
-                        "but got %s from the %s bazel dep. " % (non_root_artifact.version, bazel_dep_name) +
-                        "Please update the version in your MODULE.bazel or set `force_version = True`."
-                    )
-
-    # Add any remaining non root artifacts that weren't found in the root artifact list
-    addtional_artifact_message = ""
-    for bazel_dep_name, non_root_artifact in non_root_coordinate_to_artifact.values():
-        addtional_artifact_message = addtional_artifact_message + (
-            "\nINFO: The @%s repo is getting the additional artifact %s:%s:%s from the %s bazel dep." % (name, non_root_artifact.group, non_root_artifact.artifact, non_root_artifact.version, bazel_dep_name)
-        )
-        filtered_non_root_artifacts.append(non_root_artifact)
-
-    if repin_env_var:
-        if duplicate_artifact_warning != "":
-            print(duplicate_artifact_warning)
-        if rje_verbose_env_var:
-            if addtional_artifact_message != "":
-                print(addtional_artifact_message)
-
-    return root_artifacts + filtered_non_root_artifacts
-
 def _get_tri_state_bool(amend_val, original_val):
     if amend_val in ["true", "on"]:
         return True
@@ -590,62 +514,10 @@
     """
     return root_list + non_root_list
 
-def remove_fields(s):
-    """Used for reducing an artifact struct down to only those fields that have values"""
-    return {
-        k: getattr(s, k)
-        for k in dir(s)
-        if k != "to_json" and k != "to_proto" and getattr(s, k, None)
-    } | {"version": getattr(s, "version", "")}
-
-def _defines_gradle_module_version(candidate, current):
-    """Whether candidate should force the Gradle module version instead of current."""
-    candidate_classified = bool(getattr(candidate, "classifier", None))
-    current_classified = bool(getattr(current, "classifier", None))
-    if candidate_classified != current_classified:
-        # An unclassified root defines the module version.
-        return current_classified
-    return compare_maven_versions(candidate.version, current.version) == 1
-
-def _select_gradle_forced_versions(artifacts):
-    """Selects the single version to force for each Gradle group:artifact module.
-
-    Gradle resolves one version per module regardless of classifier, so forcing
-    two versions of the same module (for example a main jar and its
-    test-fixtures jar) makes resolution unsatisfiable.
-    """
-    winners = {}
-    for artifact in artifacts:
-        if not getattr(artifact, "version", None):
-            continue
-        key = "%s:%s" % (artifact.group, artifact.artifact)
-        current = winners.get(key)
-        if current == None or _defines_gradle_module_version(artifact, current):
-            winners[key] = artifact
-    return {key: winner.version for key, winner in winners.items()}
-
-def _forces_gradle_module_version(artifact, forced_versions):
-    version = getattr(artifact, "version", None)
-    if not version:
-        return False
-    return version == forced_versions.get("%s:%s" % (artifact.group, artifact.artifact))
-
-def apply_root_version_conflict_policy(artifacts, resolver, version_conflict_policy):
-    """Applies the install-level conflict policy to root module artifacts."""
-    if resolver not in ["gradle", "maven"] or version_conflict_policy != "pinned":
-        return artifacts
-
-    if resolver == "gradle":
-        forced_versions = _select_gradle_forced_versions(artifacts)
-        return [
-            struct(**(remove_fields(artifact) | {"force_version": True})) if _forces_gradle_module_version(artifact, forced_versions) else artifact
-            for artifact in artifacts
-        ]
-
-    return [
-        struct(**(remove_fields(artifact) | {"force_version": True})) if getattr(artifact, "version", None) else artifact
-        for artifact in artifacts
-    ]
+def _print_layering_diagnostics(diagnostics, repin_env_var, rje_verbose_env_var):
+    for diagnostic in diagnostics:
+        if should_print_diagnostic(diagnostic, repin_env_var, rje_verbose_env_var):
+            print(diagnostic.text)
 
 def maven_impl(mctx):
     repos = {}
@@ -701,62 +573,21 @@
         merged_repo.update(non_root_repo)
         merged_repo.update(root_repo)
 
-        # Special handling for artifacts and boms - deduplicate with root priority
-        root_artifacts = apply_root_version_conflict_policy(
-            root_repo.get("artifacts", []),
-            root_repo.get("resolver", _DEFAULT_RESOLVER),
-            root_repo.get("version_conflict_policy", "default"),
+        layered_artifacts_and_boms = layer_maven_namespace(
+            name = repo_name,
+            root_present = repo_name in root_module_repos,
+            root_artifacts = root_repo.get("artifacts", []),
+            root_boms = root_repo.get("boms", []),
+            resolver = root_repo.get("resolver", _DEFAULT_RESOLVER),
+            version_conflict_policy = root_repo.get("version_conflict_policy", "default"),
+            duplicate_version_warning = root_repo.get("duplicate_version_warning") or "warn",
+            known_contributing_modules = root_repo.get("known_contributing_modules", sets.make()),
+            bazel_dep_to_non_root_artifacts = non_root_repo.get("bazel_dep_to_artifacts", {}),
+            bazel_dep_to_non_root_boms = non_root_repo.get("bazel_dep_to_boms", {}),
         )
-        bazel_dep_to_non_root_artifacts = non_root_repo.get("bazel_dep_to_artifacts", {})
-        root_boms = root_repo.get("boms", [])
-        bazel_dep_to_non_root_boms = non_root_repo.get("bazel_dep_to_boms", {})
-
-        if repo_name in root_module_repos.keys():
-            known_contributing_modules = root_repo.get("known_contributing_modules", sets.make())
-            if sets.length(known_contributing_modules) == 0:
-                # Warn users if multiple modules contribute to the same maven `name`
-                _warn_if_multiple_contributing_modules(root_repo, repo_name, bazel_dep_to_non_root_artifacts)
-            else:
-                # Filter results so only modules in the known_contributing_modules add artifacts or boms
-                all_non_root_artifact_modules = bazel_dep_to_non_root_artifacts.keys()
-                bazel_dep_to_non_root_artifacts = {
-                    k: bazel_dep_to_non_root_artifacts[k]
-                    for k in sets.to_list(known_contributing_modules)
-                    if k in bazel_dep_to_non_root_artifacts
-                }
-                if rje_verbose_env_var:
-                    for k in all_non_root_artifact_modules:
-                        if k not in bazel_dep_to_non_root_artifacts.keys():
-                            print("\nINFO: The @%s repo is not using deps from %s because it is not in the known_contributing_modules" % (repo_name, k))
-                all_non_root_bom_modules = bazel_dep_to_non_root_boms.keys()
-                bazel_dep_to_non_root_boms = {
-                    k: bazel_dep_to_non_root_boms[k]
-                    for k in sets.to_list(known_contributing_modules)
-                    if k in bazel_dep_to_non_root_boms
-                }
-                if rje_verbose_env_var:
-                    for k in all_non_root_bom_modules:
-                        if k not in bazel_dep_to_non_root_boms.keys():
-                            print("\nINFO: The @%s repo is not using boms from %s because it is not in the known_contributing_modules" % (repo_name, k))
-
-            merged_repo["artifacts"] = _deduplicate_artifacts_with_root_priority(
-                repo_name,
-                root_artifacts,
-                bazel_dep_to_non_root_artifacts,
-                repin_env_var,
-                rje_verbose_env_var,
-            )
-
-            merged_repo["boms"] = _deduplicate_artifacts_with_root_priority(
-                repo_name,
-                root_boms,
-                bazel_dep_to_non_root_boms,
-                repin_env_var,
-                rje_verbose_env_var,
-            )
-        else:
-            merged_repo["artifacts"] = _deduplicate_non_root_artifacts(bazel_dep_to_non_root_artifacts, True)
-            merged_repo["boms"] = _deduplicate_non_root_artifacts(bazel_dep_to_non_root_boms, True)
+        merged_repo["artifacts"] = layered_artifacts_and_boms.artifacts
+        merged_repo["boms"] = layered_artifacts_and_boms.boms
+        _print_layering_diagnostics(layered_artifacts_and_boms.diagnostics, repin_env_var, rje_verbose_env_var)
 
         # For list attributes, concatenate but avoid duplicates (root items first)
         for list_attr in ["repositories", "excluded_artifacts", "additional_netrc_lines"]:
@@ -818,8 +649,8 @@
 
     existing_repos = []
     for (name, repo) in repos.items():
-        boms_json = [json.encode(remove_fields(b)) for b in repo.get("boms", [])]
-        artifacts_json = [json.encode(remove_fields(a)) for a in repo.get("artifacts", [])]
+        boms_json = [json.encode(remove_empty_fields(b)) for b in repo.get("boms", [])]
+        artifacts_json = [json.encode(remove_empty_fields(a)) for a in repo.get("artifacts", [])]
 
         excluded_artifacts = parse.parse_exclusion_spec_list(repo.get("excluded_artifacts", []))
         excluded_artifacts_json = [_json.write_exclusion_spec(a) for a in excluded_artifacts]
diff --git a/private/lib/layering.bzl b/private/lib/layering.bzl
new file mode 100644
index 0000000..f1c51fb
--- /dev/null
+++ b/private/lib/layering.bzl
@@ -0,0 +1,335 @@
+"""Support for layering Maven dependencies contributed by bzlmod modules."""
+
+load("@bazel_skylib//lib:new_sets.bzl", "sets")
+load("//private/lib:coordinates.bzl", "to_key")
+load("//private/rules:maven_version.bzl", "compare_maven_versions")
+
+DEFAULT_NAME = "maven"
+
+def _diagnostic(text, gate):
+    return struct(text = text, gate = gate)
+
+def should_print_diagnostic(diagnostic, repin, verbose):
+    """Whether a layering diagnostic is enabled for the current environment."""
+    return (
+        diagnostic.gate == "always" or
+        (diagnostic.gate == "repin" and repin) or
+        (diagnostic.gate == "verbose" and verbose) or
+        (diagnostic.gate == "repin_verbose" and repin and verbose)
+    )
+
+def contributing_modules_warning(repo_name, known_contributing_modules, non_root_bazel_dep_to_items):
+    """Returns the warning for contributions from modules not acknowledged by the root."""
+    contributing_module_names = non_root_bazel_dep_to_items.keys()
+    new_contributing_modules = sets.difference(sets.make(contributing_module_names), known_contributing_modules)
+    if sets.length(new_contributing_modules) > 0:
+        return (
+            "The maven repository '%s' has contributions from multiple bzlmod modules, and will be resolved together: %s." % (
+                repo_name,
+                sorted(contributing_module_names),
+            ) + "\nSee https://github.com/bazel-contrib/rules_jvm_external/blob/master/docs/bzlmod.md#module-dependency-layering" +
+            " for more information. \n" +
+            " To suppress this warning review the contributions from the other modules and add the following attribute" +
+            " in the root MODULE.bazel file: \n" +
+            "maven.install(\n" +
+            ("  name = \"{0}\"\n".format(repo_name) if repo_name != DEFAULT_NAME else "") +
+            "  known_contributing_modules = {0},\n".format(sorted(contributing_module_names)) +
+            "  ...\n" +
+            ")"
+        )
+    return None
+
+def _candidate_takes_precedence(current_artifact, candidate_artifact):
+    if current_artifact == None:
+        return True
+
+    current_forced = getattr(current_artifact, "force_version", False)
+    candidate_forced = getattr(candidate_artifact, "force_version", False)
+    if current_forced != candidate_forced:
+        return candidate_forced
+
+    return compare_maven_versions(current_artifact.version, candidate_artifact.version) == -1
+
+def _fail_if_conflicting_forces(module_name, artifacts):
+    coordinate_to_forced_artifact = {}
+    for artifact in artifacts:
+        if not getattr(artifact, "force_version", False):
+            continue
+
+        artifact_key = to_key(artifact)
+        previous_artifact = coordinate_to_forced_artifact.get(artifact_key)
+        if previous_artifact != None and compare_maven_versions(previous_artifact.version, artifact.version) != 0:
+            fail(
+                "Module '%s' forces dependency '%s' at different versions: %s and %s." % (
+                    module_name,
+                    artifact_key,
+                    previous_artifact.version,
+                    artifact.version,
+                ),
+            )
+        coordinate_to_forced_artifact[artifact_key] = artifact
+
+def deduplicate_non_root_artifacts(
+        bazel_dep_to_non_root_artifacts,
+        return_only_artifacts = False,
+        root_forced_artifact_keys = None):
+    root_forced_artifact_keys = root_forced_artifact_keys or {}
+    coordinate_to_artifact = {}
+    coordinate_to_forced_artifact = {}
+    for bazel_dep_name in bazel_dep_to_non_root_artifacts:
+        module_coordinate_to_artifact = {}
+        module_artifacts = bazel_dep_to_non_root_artifacts.get(bazel_dep_name, [])
+        _fail_if_conflicting_forces(bazel_dep_name, module_artifacts)
+        for artifact in module_artifacts:
+            if not getattr(artifact, "testonly", False):
+                artifact_key = to_key(artifact)
+                if _candidate_takes_precedence(module_coordinate_to_artifact.get(artifact_key), artifact):
+                    module_coordinate_to_artifact[artifact_key] = artifact
+
+        for artifact_key, artifact in module_coordinate_to_artifact.items():
+            if getattr(artifact, "force_version", False) and artifact_key not in root_forced_artifact_keys:
+                previous_force = coordinate_to_forced_artifact.get(artifact_key)
+                if previous_force:
+                    previous_bazel_dep_name, previous_artifact = previous_force
+                    if compare_maven_versions(previous_artifact.version, artifact.version) != 0:
+                        fail(
+                            "Conflicting forced versions for dependency '%s': %s wants %s, %s wants %s. " % (
+                                artifact_key,
+                                previous_bazel_dep_name,
+                                previous_artifact.version,
+                                bazel_dep_name,
+                                artifact.version,
+                            ) +
+                            "Add an `artifact` tag to the root module at the version you want and set `force_version = True`.",
+                        )
+                else:
+                    coordinate_to_forced_artifact[artifact_key] = (bazel_dep_name, artifact)
+
+            current = coordinate_to_artifact.get(artifact_key)
+            current_artifact = current[1] if current else None
+            if _candidate_takes_precedence(current_artifact, artifact):
+                coordinate_to_artifact[artifact_key] = (bazel_dep_name, artifact)
+
+    if return_only_artifacts:
+        return [v[1] for v in coordinate_to_artifact.values()]
+    else:
+        return coordinate_to_artifact
+
+# Each bzlmod module may contribute jars to different rules_jvm_external maven repo namespaces.
+# We emit a warning to the user if a module overrides an artifact version in the root maven repo.
+#
+# This can be typical for the default @maven namespace, if a bzlmod dependency
+# wishes to contribute to the users' jars.
+def merge_with_root_priority(
+        name,
+        root_artifacts,
+        bazel_dep_to_non_root_artifacts,
+        duplicate_version_warning = "warn"):
+    """Deduplicate artifacts, giving priority to root module artifacts with force_version set."""
+    root_forced_artifact_keys = {
+        to_key(artifact): True
+        for artifact in root_artifacts
+        if getattr(artifact, "force_version", False)
+    }
+    non_root_coordinate_to_artifact = deduplicate_non_root_artifacts(
+        bazel_dep_to_non_root_artifacts,
+        root_forced_artifact_keys = root_forced_artifact_keys,
+    )
+
+    duplicate_artifact_warning = ""
+    filtered_root_artifacts = []
+    filtered_non_root_artifacts = []
+    for root_artifact in root_artifacts:
+        keep_root_artifact = True
+        artifact_key = to_key(root_artifact)
+        if artifact_key in non_root_coordinate_to_artifact:
+            bazel_dep_name, non_root_artifact = non_root_coordinate_to_artifact.pop(artifact_key)
+            if not getattr(root_artifact, "force_version", False):
+                comparison = compare_maven_versions(root_artifact.version, non_root_artifact.version)
+                non_root_forced = getattr(non_root_artifact, "force_version", False)
+                if non_root_forced or comparison == -1:
+                    keep_root_artifact = False
+                    filtered_non_root_artifacts.append(non_root_artifact)
+
+                if comparison != 0 and (non_root_forced or comparison == -1):
+                    message = (
+                        "For dependency '%s:%s' the root @%s repo wants version %s, " % (root_artifact.group, root_artifact.artifact, name, root_artifact.version) +
+                        "but got %s from the %s bazel dep. " % (non_root_artifact.version, bazel_dep_name) +
+                        "Please update the version in your MODULE.bazel or set `force_version = True`."
+                    )
+                    if duplicate_version_warning == "error":
+                        fail(message)
+                    elif duplicate_version_warning == "warn":
+                        duplicate_artifact_warning = duplicate_artifact_warning + "\nWARNING: " + message
+        if keep_root_artifact:
+            filtered_root_artifacts.append(root_artifact)
+
+    # Add any remaining non root artifacts that weren't found in the root artifact list
+    addtional_artifact_message = ""
+    for bazel_dep_name, non_root_artifact in non_root_coordinate_to_artifact.values():
+        addtional_artifact_message = addtional_artifact_message + (
+            "\nINFO: The @%s repo is getting the additional artifact %s:%s:%s from the %s bazel dep." % (name, non_root_artifact.group, non_root_artifact.artifact, non_root_artifact.version, bazel_dep_name)
+        )
+        filtered_non_root_artifacts.append(non_root_artifact)
+
+    diagnostics = []
+    if duplicate_artifact_warning != "":
+        diagnostics.append(_diagnostic(duplicate_artifact_warning, "always"))
+    if addtional_artifact_message != "":
+        diagnostics.append(_diagnostic(addtional_artifact_message, "repin_verbose"))
+
+    return struct(
+        artifacts = filtered_root_artifacts + filtered_non_root_artifacts,
+        diagnostics = diagnostics,
+    )
+
+def filter_known_contributing_modules(name, known_contributing_modules, bazel_dep_to_items, item_kind):
+    """Filters contributions to modules acknowledged by the root."""
+    all_non_root_modules = bazel_dep_to_items.keys()
+    filtered = {
+        module: bazel_dep_to_items[module]
+        for module in sets.to_list(known_contributing_modules)
+        if module in bazel_dep_to_items
+    }
+    diagnostics = []
+    for module in all_non_root_modules:
+        if module not in filtered:
+            diagnostics.append(_diagnostic(
+                "\nINFO: The @%s repo is not using %s from %s because it is not in the known_contributing_modules" % (name, item_kind, module),
+                "verbose",
+            ))
+    return struct(filtered = filtered, diagnostics = diagnostics)
+
+def layer_maven_namespace(
+        name,
+        root_present,
+        root_artifacts,
+        root_boms,
+        resolver,
+        version_conflict_policy,
+        duplicate_version_warning,
+        known_contributing_modules,
+        bazel_dep_to_non_root_artifacts,
+        bazel_dep_to_non_root_boms):
+    """Layers the dependency declarations for one Maven repository namespace."""
+    _fail_if_conflicting_forces("root", root_artifacts)
+    _fail_if_conflicting_forces("root", root_boms)
+    root_artifacts = apply_root_version_conflict_policy(
+        root_artifacts,
+        resolver,
+        version_conflict_policy,
+    )
+    diagnostics = []
+
+    if not root_present:
+        return struct(
+            artifacts = deduplicate_non_root_artifacts(bazel_dep_to_non_root_artifacts, True),
+            boms = deduplicate_non_root_artifacts(bazel_dep_to_non_root_boms, True),
+            diagnostics = diagnostics,
+        )
+
+    if sets.length(known_contributing_modules) == 0:
+        warning = contributing_modules_warning(
+            name,
+            known_contributing_modules,
+            bazel_dep_to_non_root_artifacts | bazel_dep_to_non_root_boms,
+        )
+        if warning:
+            diagnostics.append(_diagnostic(warning, "always"))
+    else:
+        filtered_artifacts = filter_known_contributing_modules(
+            name,
+            known_contributing_modules,
+            bazel_dep_to_non_root_artifacts,
+            "deps",
+        )
+        bazel_dep_to_non_root_artifacts = filtered_artifacts.filtered
+        diagnostics.extend(filtered_artifacts.diagnostics)
+
+        filtered_boms = filter_known_contributing_modules(
+            name,
+            known_contributing_modules,
+            bazel_dep_to_non_root_boms,
+            "boms",
+        )
+        bazel_dep_to_non_root_boms = filtered_boms.filtered
+        diagnostics.extend(filtered_boms.diagnostics)
+
+    artifacts = merge_with_root_priority(
+        name,
+        root_artifacts,
+        bazel_dep_to_non_root_artifacts,
+        duplicate_version_warning,
+    )
+    diagnostics.extend(artifacts.diagnostics)
+
+    boms = merge_with_root_priority(
+        name,
+        root_boms,
+        bazel_dep_to_non_root_boms,
+        duplicate_version_warning,
+    )
+    diagnostics.extend(boms.diagnostics)
+
+    return struct(
+        artifacts = artifacts.artifacts,
+        boms = boms.artifacts,
+        diagnostics = diagnostics,
+    )
+
+def remove_empty_fields(s):
+    """Used for reducing an artifact struct down to only those fields that have values"""
+    return {
+        k: getattr(s, k)
+        for k in dir(s)
+        if k != "to_json" and k != "to_proto" and getattr(s, k, None)
+    } | {"version": getattr(s, "version", "")}
+
+def _defines_gradle_module_version(candidate, current):
+    """Whether candidate should force the Gradle module version instead of current."""
+    candidate_classified = bool(getattr(candidate, "classifier", None))
+    current_classified = bool(getattr(current, "classifier", None))
+    if candidate_classified != current_classified:
+        # An unclassified root defines the module version.
+        return current_classified
+    return compare_maven_versions(candidate.version, current.version) == 1
+
+def _select_gradle_forced_versions(artifacts):
+    """Selects the single version to force for each Gradle group:artifact module.
+
+    Gradle resolves one version per module regardless of classifier, so forcing
+    two versions of the same module (for example a main jar and its
+    test-fixtures jar) makes resolution unsatisfiable.
+    """
+    winners = {}
+    for artifact in artifacts:
+        if not getattr(artifact, "version", None):
+            continue
+        key = "%s:%s" % (artifact.group, artifact.artifact)
+        current = winners.get(key)
+        if current == None or _defines_gradle_module_version(artifact, current):
+            winners[key] = artifact
+    return {key: winner.version for key, winner in winners.items()}
+
+def _forces_gradle_module_version(artifact, forced_versions):
+    version = getattr(artifact, "version", None)
+    if not version:
+        return False
+    return version == forced_versions.get("%s:%s" % (artifact.group, artifact.artifact))
+
+def apply_root_version_conflict_policy(artifacts, resolver, version_conflict_policy):
+    """Applies the install-level conflict policy to root module artifacts."""
+    if resolver not in ["gradle", "maven"] or version_conflict_policy != "pinned":
+        return artifacts
+
+    if resolver == "gradle":
+        forced_versions = _select_gradle_forced_versions(artifacts)
+        return [
+            struct(**(remove_empty_fields(artifact) | {"force_version": True})) if _forces_gradle_module_version(artifact, forced_versions) else artifact
+            for artifact in artifacts
+        ]
+
+    return [
+        struct(**(remove_empty_fields(artifact) | {"force_version": True})) if getattr(artifact, "version", None) else artifact
+        for artifact in artifacts
+    ]
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 8f20e49..e5a9cb8 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -578,10 +578,10 @@
     repositories = [json.decode(repository) for repository in repository_ctx.attr.repositories]
 
     artifacts = [json.decode(artifact) for artifact in repository_ctx.attr.artifacts]
-    _check_artifacts_are_unique(artifacts, repository_ctx.attr.duplicate_version_warning)
+    check_artifacts_are_unique(artifacts, repository_ctx.attr.duplicate_version_warning)
 
     boms = [json.decode(bom) for bom in repository_ctx.attr.boms]
-    _check_artifacts_are_unique(boms, repository_ctx.attr.duplicate_version_warning)
+    check_artifacts_are_unique(boms, repository_ctx.attr.duplicate_version_warning)
 
     # Read Coursier state from maven_install.json.
     repository_ctx.symlink(
@@ -937,7 +937,7 @@
             break
     return primary_artifact_path
 
-def _check_artifacts_are_unique(artifacts, duplicate_version_warning):
+def check_artifacts_are_unique(artifacts, duplicate_version_warning):
     if duplicate_version_warning == "none":
         return
     seen_artifacts = {}
@@ -962,7 +962,9 @@
         if duplicate_version_warning == "error":
             fail("\n".join(msg_parts))
         else:
-            print("\n".join(msg_parts))
+            message = "\n".join(msg_parts)
+            print(message)
+            return message
 
 def get_coursier_sha256(environ, default_sha256):
     return environ.get("COURSIER_SHA256", default_sha256)
@@ -1284,10 +1286,10 @@
     for artifact in repository_ctx.attr.artifacts:
         artifacts.append(json.decode(artifact))
 
-    _check_artifacts_are_unique(artifacts, repository_ctx.attr.duplicate_version_warning)
+    check_artifacts_are_unique(artifacts, repository_ctx.attr.duplicate_version_warning)
 
     boms = [json.decode(bom) for bom in repository_ctx.attr.boms]
-    _check_artifacts_are_unique(boms, repository_ctx.attr.duplicate_version_warning)
+    check_artifacts_are_unique(boms, repository_ctx.attr.duplicate_version_warning)
 
     excluded_artifacts = []
     for artifact in repository_ctx.attr.excluded_artifacts:
diff --git a/tests/bazel_run_tests.sh b/tests/bazel_run_tests.sh
index 655edef..1166aa2 100755
--- a/tests/bazel_run_tests.sh
+++ b/tests/bazel_run_tests.sh
@@ -32,6 +32,17 @@
   expect_log "Successfully pinned resolved artifacts"
 }
 
+function test_duplicate_version_error() {
+  if bazel run @duplicate_version_error//:pin >> "$TEST_LOG" 2>&1; then
+    printf "Expected duplicate artifact versions to fail the build\n" >> "$TEST_LOG"
+    return 1
+  fi
+
+  expect_log "Found duplicate artifact versions"
+  expect_log "com.fasterxml.jackson.core:jackson-annotations has multiple versions"
+  expect_not_log "Successfully pinned resolved artifacts"
+}
+
 function test_duplicate_version_warning_same_version() {
   bazel run @duplicate_version_warning_same_version//:pin >> "$TEST_LOG" 2>&1
   rm -f *duplicate_version_warning_same_version_install.json
@@ -450,6 +461,7 @@
   "test_coursier_resolution_with_boms"
   "test_maven_resolution"
   "test_dependency_aggregation"
+  "test_duplicate_version_error"
   "test_duplicate_version_warning"
   "test_duplicate_version_warning_same_version"
   "test_outdated"
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
index c7449bd..8308563 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
@@ -66,6 +66,19 @@
   }
 
   @Test
+  public void duplicateDirectDependenciesUseHighestVersion() {
+    Coordinates lower = new Coordinates("com.example:library:1.0");
+    Coordinates higher = new Coordinates("com.example:library:2.0");
+    Path repo = MavenRepo.create().add(lower).add(higher).getPath();
+
+    Graph<Coordinates> resolved =
+        resolver.resolve(prepareRequestFor(repo.toUri(), lower, higher)).getResolution();
+
+    assertFalse(resolved.nodes().contains(lower));
+    assertTrue(resolved.nodes().contains(higher));
+  }
+
+  @Test
   public void resolvesSimpleJvmVariant() throws IOException, XMLStreamException {
     // This test validates gradle can resolve a artifact using only gradle module metadata
     // In this case, there's a root artifact com.example.sample which points to
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolverTest.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolverTest.java
index 08d7862..663cbdf 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolverTest.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolverTest.java
@@ -14,6 +14,8 @@
 
 package com.github.bazelbuild.rules_jvm_external.resolver.maven;
 
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 import com.github.bazelbuild.rules_jvm_external.Coordinates;
@@ -35,6 +37,18 @@
   }
 
   @Test
+  public void duplicateDirectDependenciesUseFirstVersion() {
+    Coordinates first = new Coordinates("com.example:library:1.0");
+    Coordinates second = new Coordinates("com.example:library:2.0");
+    Path repo = MavenRepo.create().add(first).add(second).getPath();
+
+    var resolved = resolver.resolve(prepareRequestFor(repo.toUri(), first, second)).getResolution();
+
+    assertTrue(resolved.nodes().contains(first));
+    assertFalse(resolved.nodes().contains(second));
+  }
+
+  @Test
   public void shouldSuccessfullyResolveNettyStaticClasses() {
     Coordinates main = new Coordinates("com.example:root:1.0.0");
     Coordinates x86Dep = new Coordinates("com.example", "root", null, "linux-x86_64", "1.0.0");
diff --git a/tests/integration/BUILD b/tests/integration/BUILD
index 21848b1..71654c7 100644
--- a/tests/integration/BUILD
+++ b/tests/integration/BUILD
@@ -154,6 +154,31 @@
     tags = [] if is_bzlmod_enabled() else ["manual"],
 )
 
+genquery(
+    name = "coursier-layering-deps",
+    expression = "deps(@coursier_layering//:com_google_code_findbugs_jsr305)",
+    opts = [
+        "--nohost_deps",
+        "--noimplicit_deps",
+    ],
+    scope = ["@coursier_layering//:com_google_code_findbugs_jsr305"],
+)
+
+genrule(
+    name = "coursier-layering-deps-sorted",
+    testonly = 1,
+    srcs = [":coursier-layering-deps"],
+    outs = ["coursier-layering-deps-sorted.txt"],
+    cmd = "cat $< | grep coursier_layering | sed -e 's|^@@|@|g; s|\\r||g' | sed -e 's|^@[^/]*[+~]|@|g; s|\\r||g' | sort > $@",
+)
+
+diff_test(
+    name = "coursier-layering-deps-test",
+    file1 = "coursier-layering-deps.golden.unix",
+    file2 = ":coursier-layering-deps-sorted.txt",
+    tags = [] if is_bzlmod_enabled() else ["manual"],
+)
+
 # This target will fail to build if we're not handling merging of maven.install
 # tags properly, and if we don't handle multiple lock files properly.
 java_library(
diff --git a/tests/integration/coursier-layering-deps.golden.unix b/tests/integration/coursier-layering-deps.golden.unix
new file mode 100644
index 0000000..115469f
--- /dev/null
+++ b/tests/integration/coursier-layering-deps.golden.unix
@@ -0,0 +1,2 @@
+@coursier_layering//:com_google_code_findbugs_jsr305
+@coursier_layering//:v1/https/repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar
diff --git a/tests/integration/coursier_higher_layer/MODULE.bazel b/tests/integration/coursier_higher_layer/MODULE.bazel
new file mode 100644
index 0000000..16f3d13
--- /dev/null
+++ b/tests/integration/coursier_higher_layer/MODULE.bazel
@@ -0,0 +1,16 @@
+module(name = "coursier_higher_layer")
+
+bazel_dep(name = "rules_jvm_external", version = "0.0")
+local_path_override(
+    module_name = "rules_jvm_external",
+    path = "../../../",
+)
+
+maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
+maven.install(
+    name = "coursier_layering",
+    artifacts = [
+        "com.google.code.findbugs:jsr305:3.0.2",
+    ],
+)
+use_repo(maven, "coursier_layering")
diff --git a/tests/unit/BUILD b/tests/unit/BUILD
index f50a066..e62bfea 100644
--- a/tests/unit/BUILD
+++ b/tests/unit/BUILD
@@ -6,6 +6,7 @@
 load(":coursier_utilities_test.bzl", "coursier_utilities_test_suite")
 load(":dependency_tree_parser_test.bzl", "dependency_tree_parser_test_suite")
 load(":java_utilities_test.bzl", "java_utilities_test_suite")
+load(":layering_test.bzl", "layering_test_suite")
 load(":maven_version_test.bzl", "maven_version_test_suite")
 load(":proxy_test.bzl", "proxy_test_suite")
 load(":specs_test.bzl", "artifact_specs_test_suite")
@@ -31,6 +32,8 @@
 
 java_utilities_test_suite()
 
+layering_test_suite()
+
 maven_version_test_suite()
 
 proxy_test_suite()
diff --git a/tests/unit/coursier_test.bzl b/tests/unit/coursier_test.bzl
index a542aaa..62a09c9 100644
--- a/tests/unit/coursier_test.bzl
+++ b/tests/unit/coursier_test.bzl
@@ -2,6 +2,7 @@
 load("//private/lib:urls.bzl", "extract_netrc_from_auth_url", "remove_auth_from_url", "split_url")
 load(
     "//private/rules:coursier.bzl",
+    "check_artifacts_are_unique",
     "compute_dependency_inputs_signature",
     "get_coursier_cache_or_default",
     "get_coursier_environment",
@@ -20,6 +21,35 @@
     ALL_TESTS.append(test)
     return test
 
+def _packaging_does_not_distinguish_duplicate_versions_test_impl(ctx):
+    env = unittest.begin(ctx)
+    artifacts = [
+        {
+            "group": "com.example",
+            "artifact": "library",
+            "version": "1.0",
+            "packaging": "jar",
+            "classifier": None,
+        },
+        {
+            "group": "com.example",
+            "artifact": "library",
+            "version": "2.0",
+            "packaging": "aar",
+            "classifier": None,
+        },
+    ]
+
+    asserts.equals(
+        env,
+        "Found duplicate artifact versions\n    com.example:library has multiple versions 1.0, 2.0\nPlease remove duplicate artifacts from the artifact list so you do not get unexpected artifact versions",
+        check_artifacts_are_unique(artifacts, "warn"),
+    )
+
+    return unittest.end(env)
+
+packaging_does_not_distinguish_duplicate_versions_test = add_test(_packaging_does_not_distinguish_duplicate_versions_test_impl)
+
 def _infer_doc_example_test_impl(ctx):
     env = unittest.begin(ctx)
     asserts.equals(
diff --git a/tests/unit/layering_test.bzl b/tests/unit/layering_test.bzl
new file mode 100644
index 0000000..1abb461
--- /dev/null
+++ b/tests/unit/layering_test.bzl
@@ -0,0 +1,878 @@
+"""Tests for dependency layering across bzlmod modules."""
+
+load("@bazel_skylib//lib:new_sets.bzl", "sets")
+load("@bazel_skylib//lib:partial.bzl", "partial")
+load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest")
+load(
+    "//private/lib:layering.bzl",
+    "deduplicate_non_root_artifacts",
+    "filter_known_contributing_modules",
+    "layer_maven_namespace",
+    "merge_with_root_priority",
+    "should_print_diagnostic",
+)
+
+def _artifact(
+        version,
+        force_version = False,
+        testonly = False,
+        neverlink = False,
+        exclusions = None,
+        packaging = None,
+        classifier = None,
+        group = "com.example",
+        artifact = "library"):
+    return struct(
+        group = group,
+        artifact = artifact,
+        version = version,
+        packaging = packaging,
+        classifier = classifier,
+        force_version = force_version,
+        testonly = testonly,
+        neverlink = neverlink,
+        exclusions = exclusions or [],
+    )
+
+def _merge_result(
+        root_artifacts,
+        bazel_dep_to_non_root_artifacts,
+        duplicate_version_warning = "warn"):
+    return merge_with_root_priority(
+        "maven",
+        root_artifacts,
+        bazel_dep_to_non_root_artifacts,
+        duplicate_version_warning,
+    )
+
+def _merge(root_artifacts, bazel_dep_to_non_root_artifacts):
+    return _merge_result(root_artifacts, bazel_dep_to_non_root_artifacts).artifacts
+
+def _layer(
+        name = "maven",
+        root_present = True,
+        root_artifacts = None,
+        root_boms = None,
+        resolver = "coursier",
+        version_conflict_policy = "default",
+        duplicate_version_warning = "warn",
+        known_contributing_modules = None,
+        non_root_artifacts = None,
+        non_root_boms = None):
+    return layer_maven_namespace(
+        name = name,
+        root_present = root_present,
+        root_artifacts = root_artifacts or [],
+        root_boms = root_boms or [],
+        resolver = resolver,
+        version_conflict_policy = version_conflict_policy,
+        duplicate_version_warning = duplicate_version_warning,
+        known_contributing_modules = known_contributing_modules or sets.make(),
+        bazel_dep_to_non_root_artifacts = non_root_artifacts or {},
+        bazel_dep_to_non_root_boms = non_root_boms or {},
+    )
+
+def _root_only_resolves_root_version_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0")
+
+    asserts.equals(env, [root], _merge([root], {}))
+
+    return unittest.end(env)
+
+root_only_resolves_root_version_test = unittest.make(_root_only_resolves_root_version_impl)
+
+def _nonroot_lower_version_is_dropped_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("2.0")
+
+    result = _merge_result([root], {"dep": [_artifact("1.0")]})
+
+    asserts.equals(env, [root], result.artifacts)
+    asserts.equals(env, [], result.diagnostics)
+
+    return unittest.end(env)
+
+nonroot_lower_version_is_dropped_test = unittest.make(_nonroot_lower_version_is_dropped_impl)
+
+def _equal_unforced_nonroot_keeps_root_metadata_without_warning_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact(
+        "1.0",
+        neverlink = True,
+        exclusions = [struct(group = "excluded", artifact = "root")],
+    )
+
+    result = _merge_result(
+        [root],
+        {"dep": [_artifact(
+            "1.0",
+            exclusions = [struct(group = "excluded", artifact = "non-root")],
+        )]},
+    )
+
+    asserts.equals(env, [root], result.artifacts)
+    asserts.equals(env, [], result.diagnostics)
+
+    return unittest.end(env)
+
+equal_unforced_nonroot_keeps_root_metadata_without_warning_test = unittest.make(_equal_unforced_nonroot_keeps_root_metadata_without_warning_impl)
+
+def _nonroot_higher_version_wins_and_warns_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0")
+    non_root = _artifact("2.0")
+
+    result = _merge_result([root], {"dep": [non_root]})
+
+    asserts.equals(env, [non_root], result.artifacts)
+    asserts.equals(
+        env,
+        [struct(
+            text = "\nWARNING: For dependency 'com.example:library' the root @maven repo wants version 1.0, but got 2.0 from the dep bazel dep. Please update the version in your MODULE.bazel or set `force_version = True`.",
+            gate = "always",
+        )],
+        result.diagnostics,
+    )
+
+    return unittest.end(env)
+
+nonroot_higher_version_wins_and_warns_test = unittest.make(_nonroot_higher_version_wins_and_warns_impl)
+
+def _nonroot_override_warning_can_be_disabled_impl(ctx):
+    env = unittest.begin(ctx)
+    non_root = _artifact("2.0")
+
+    result = _layer(
+        root_artifacts = [_artifact("1.0")],
+        duplicate_version_warning = "none",
+        known_contributing_modules = sets.make(["dep"]),
+        non_root_artifacts = {"dep": [non_root]},
+    )
+
+    asserts.equals(env, [non_root], result.artifacts)
+    asserts.equals(env, [], result.diagnostics)
+
+    return unittest.end(env)
+
+nonroot_override_warning_can_be_disabled_test = unittest.make(_nonroot_override_warning_can_be_disabled_impl)
+
+def _higher_nonroot_force_beats_unforced_root_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0")
+    non_root = _artifact("2.0", force_version = True)
+
+    result = _merge_result([root], {"dep": [non_root]})
+
+    asserts.equals(env, [non_root], result.artifacts)
+    asserts.true(env, result.artifacts[0].force_version)
+    asserts.equals(env, "always", result.diagnostics[0].gate)
+
+    return unittest.end(env)
+
+higher_nonroot_force_beats_unforced_root_test = unittest.make(_higher_nonroot_force_beats_unforced_root_impl)
+
+def _lower_nonroot_force_beats_unforced_root_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("2.0")
+    non_root = _artifact("1.0", force_version = True)
+
+    result = _merge_result([root], {"dep": [non_root]})
+
+    asserts.equals(env, [non_root], result.artifacts)
+    asserts.equals(env, "always", result.diagnostics[0].gate)
+
+    return unittest.end(env)
+
+lower_nonroot_force_beats_unforced_root_test = unittest.make(_lower_nonroot_force_beats_unforced_root_impl)
+
+def _equal_nonroot_force_retains_transitive_pin_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0")
+    non_root = _artifact("1.0", force_version = True)
+
+    result = _merge_result([root], {"dep": [non_root]})
+
+    asserts.equals(env, [non_root], result.artifacts)
+    asserts.true(env, result.artifacts[0].force_version)
+    asserts.equals(env, [], result.diagnostics)
+
+    return unittest.end(env)
+
+equal_nonroot_force_retains_transitive_pin_test = unittest.make(_equal_nonroot_force_retains_transitive_pin_impl)
+
+def _both_forced_root_wins_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0", force_version = True)
+
+    asserts.equals(
+        env,
+        [root],
+        _merge([root], {"dep": [_artifact("2.0", force_version = True)]}),
+    )
+
+    return unittest.end(env)
+
+both_forced_root_wins_test = unittest.make(_both_forced_root_wins_impl)
+
+def _root_force_beats_conflicting_nonroot_forces_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("3.0", force_version = True)
+
+    asserts.equals(
+        env,
+        [root],
+        _merge([root], _conflicting_nonroot_forces()),
+    )
+
+    return unittest.end(env)
+
+root_force_beats_conflicting_nonroot_forces_test = unittest.make(_root_force_beats_conflicting_nonroot_forces_impl)
+
+def _single_nonroot_survives_impl(ctx):
+    env = unittest.begin(ctx)
+    non_root = _artifact("1.0")
+
+    asserts.equals(
+        env,
+        [non_root],
+        deduplicate_non_root_artifacts({"dep": [non_root]}, return_only_artifacts = True),
+    )
+
+    return unittest.end(env)
+
+single_nonroot_survives_test = unittest.make(_single_nonroot_survives_impl)
+
+def _testonly_nonroot_is_filtered_impl(ctx):
+    env = unittest.begin(ctx)
+
+    asserts.equals(
+        env,
+        [],
+        deduplicate_non_root_artifacts(
+            {"dep": [_artifact("1.0", testonly = True)]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+testonly_nonroot_is_filtered_test = unittest.make(_testonly_nonroot_is_filtered_impl)
+
+def _multiple_nonroot_highest_wins_impl(ctx):
+    env = unittest.begin(ctx)
+    higher = _artifact("2.0")
+
+    asserts.equals(
+        env,
+        [higher],
+        deduplicate_non_root_artifacts(
+            {"first": [_artifact("1.0")], "second": [higher]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+multiple_nonroot_highest_wins_test = unittest.make(_multiple_nonroot_highest_wins_impl)
+
+def _equal_version_tie_keeps_first_module_metadata_impl(ctx):
+    env = unittest.begin(ctx)
+    first = _artifact(
+        "1.0",
+        force_version = True,
+        neverlink = True,
+        exclusions = [struct(group = "excluded", artifact = "first")],
+    )
+    second = _artifact(
+        "1.0",
+        exclusions = [struct(group = "excluded", artifact = "second")],
+    )
+
+    asserts.equals(
+        env,
+        [first],
+        deduplicate_non_root_artifacts(
+            {"first": [first], "second": [second]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+equal_version_tie_keeps_first_module_metadata_test = unittest.make(_equal_version_tie_keeps_first_module_metadata_impl)
+
+def _root_force_beats_higher_unforced_nonroot_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0", force_version = True)
+
+    asserts.equals(env, [root], _merge([root], {"dep": [_artifact("2.0")]}))
+
+    return unittest.end(env)
+
+root_force_beats_higher_unforced_nonroot_test = unittest.make(_root_force_beats_higher_unforced_nonroot_impl)
+
+def _conflicting_nonroot_forces():
+    return {
+        "first": [_artifact("1.0", force_version = True)],
+        "second": [_artifact("2.0", force_version = True)],
+    }
+
+def _conflicting_nonroot_forces_target_impl(_ctx):
+    deduplicate_non_root_artifacts(_conflicting_nonroot_forces(), return_only_artifacts = True)
+    return []
+
+conflicting_nonroot_forces_target = rule(implementation = _conflicting_nonroot_forces_target_impl)
+
+def _same_module_forces_target_impl(ctx):
+    artifacts = [
+        _artifact("1.0", force_version = True),
+        _artifact(ctx.attr.second_version, force_version = True),
+    ]
+    if ctx.attr.root:
+        _layer(root_artifacts = artifacts)
+    else:
+        deduplicate_non_root_artifacts({"dep": artifacts}, return_only_artifacts = True)
+    return []
+
+same_module_forces_target = rule(
+    implementation = _same_module_forces_target_impl,
+    attrs = {
+        "root": attr.bool(),
+        "second_version": attr.string(mandatory = True),
+    },
+)
+
+def _nonroot_override_error_target_impl(_ctx):
+    _layer(
+        root_artifacts = [_artifact("1.0")],
+        duplicate_version_warning = "error",
+        known_contributing_modules = sets.make(["dep"]),
+        non_root_artifacts = {"dep": [_artifact("2.0")]},
+    )
+    return []
+
+nonroot_override_error_target = rule(implementation = _nonroot_override_error_target_impl)
+
+def _unforced_root_with_conflicting_nonroot_forces_target_impl(_ctx):
+    merge_with_root_priority(
+        "maven",
+        [_artifact("3.0")],
+        _conflicting_nonroot_forces(),
+    )
+    return []
+
+unforced_root_with_conflicting_nonroot_forces_target = rule(
+    implementation = _unforced_root_with_conflicting_nonroot_forces_target_impl,
+)
+
+def _conflicting_nonroot_forces_fail_impl(ctx):
+    env = analysistest.begin(ctx)
+
+    asserts.expect_failure(
+        env,
+        "Conflicting forced versions for dependency 'com.example:library': first wants 1.0, second wants 2.0. Add an `artifact` tag to the root module at the version you want and set `force_version = True`.",
+    )
+
+    return analysistest.end(env)
+
+conflicting_nonroot_forces_fail_test = analysistest.make(
+    _conflicting_nonroot_forces_fail_impl,
+    expect_failure = True,
+)
+
+unforced_root_with_conflicting_nonroot_forces_fail_test = analysistest.make(
+    _conflicting_nonroot_forces_fail_impl,
+    expect_failure = True,
+)
+
+def _nonroot_override_error_fails_impl(ctx):
+    env = analysistest.begin(ctx)
+
+    asserts.expect_failure(
+        env,
+        "For dependency 'com.example:library' the root @maven repo wants version 1.0, but got 2.0 from the dep bazel dep.",
+    )
+
+    return analysistest.end(env)
+
+nonroot_override_error_fails_test = analysistest.make(
+    _nonroot_override_error_fails_impl,
+    expect_failure = True,
+)
+
+def _same_module_forces_fail_impl(ctx):
+    env = analysistest.begin(ctx)
+
+    asserts.expect_failure(env, ctx.attr.expected_message)
+
+    return analysistest.end(env)
+
+different_forced_versions_from_same_nonroot_module_fail_test = analysistest.make(
+    _same_module_forces_fail_impl,
+    attrs = {"expected_message": attr.string(mandatory = True)},
+    expect_failure = True,
+)
+
+different_forced_versions_from_root_module_fail_test = analysistest.make(
+    _same_module_forces_fail_impl,
+    attrs = {"expected_message": attr.string(mandatory = True)},
+    expect_failure = True,
+)
+
+def _matching_nonroot_forces_keep_first_module_impl(ctx):
+    env = unittest.begin(ctx)
+    first = _artifact("1.0", force_version = True, neverlink = True)
+
+    asserts.equals(
+        env,
+        [first],
+        deduplicate_non_root_artifacts(
+            {
+                "first": [first],
+                "second": [_artifact("1.0", force_version = True)],
+            },
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+matching_nonroot_forces_keep_first_module_test = unittest.make(_matching_nonroot_forces_keep_first_module_impl)
+
+def _matching_forced_duplicates_from_same_nonroot_module_keep_first_impl(ctx):
+    env = unittest.begin(ctx)
+    first = _artifact("1.0", force_version = True, neverlink = True)
+
+    asserts.equals(
+        env,
+        [first],
+        deduplicate_non_root_artifacts(
+            {"dep": [first, _artifact("1.0", force_version = True)]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+matching_forced_duplicates_from_same_nonroot_module_keep_first_test = unittest.make(_matching_forced_duplicates_from_same_nonroot_module_keep_first_impl)
+
+def _matching_forced_duplicates_from_root_module_remain_for_repository_check_impl(ctx):
+    env = unittest.begin(ctx)
+    first = _artifact("1.0", force_version = True, neverlink = True)
+    second = _artifact("1.0", force_version = True)
+
+    asserts.equals(env, [first, second], _layer(root_artifacts = [first, second]).artifacts)
+
+    return unittest.end(env)
+
+matching_forced_duplicates_from_root_module_remain_for_repository_check_test = unittest.make(_matching_forced_duplicates_from_root_module_remain_for_repository_check_impl)
+
+def _forced_nonroot_beats_higher_unforced_nonroot_impl(ctx):
+    env = unittest.begin(ctx)
+    forced = _artifact("1.0", force_version = True)
+
+    asserts.equals(
+        env,
+        [forced],
+        deduplicate_non_root_artifacts(
+            {
+                "first": [_artifact("2.0")],
+                "second": [forced],
+            },
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+forced_nonroot_beats_higher_unforced_nonroot_test = unittest.make(_forced_nonroot_beats_higher_unforced_nonroot_impl)
+
+def _forced_nonroot_beats_higher_duplicate_from_same_module_impl(ctx):
+    env = unittest.begin(ctx)
+    forced = _artifact("1.0", force_version = True)
+
+    asserts.equals(
+        env,
+        [forced],
+        deduplicate_non_root_artifacts(
+            {"dep": [_artifact("2.0"), forced]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+forced_nonroot_beats_higher_duplicate_from_same_module_test = unittest.make(_forced_nonroot_beats_higher_duplicate_from_same_module_impl)
+
+def _forced_classifiers_from_same_module_layer_independently_impl(ctx):
+    env = unittest.begin(ctx)
+    plain = _artifact("1.0", force_version = True)
+    classified = _artifact("2.0", force_version = True, classifier = "tests")
+
+    asserts.equals(
+        env,
+        [plain, classified],
+        deduplicate_non_root_artifacts(
+            {"dep": [plain, classified]},
+            return_only_artifacts = True,
+        ),
+    )
+
+    return unittest.end(env)
+
+forced_classifiers_from_same_module_layer_independently_test = unittest.make(_forced_classifiers_from_same_module_layer_independently_impl)
+
+def _pinned_maven_synthesized_root_forces_do_not_conflict_impl(ctx):
+    env = unittest.begin(ctx)
+
+    result = _layer(
+        root_artifacts = [_artifact("1.0"), _artifact("2.0")],
+        resolver = "maven",
+        version_conflict_policy = "pinned",
+    )
+
+    asserts.equals(env, ["1.0", "2.0"], [artifact.version for artifact in result.artifacts])
+    asserts.equals(env, [True, True], [artifact.force_version for artifact in result.artifacts])
+
+    return unittest.end(env)
+
+pinned_maven_synthesized_root_forces_do_not_conflict_test = unittest.make(_pinned_maven_synthesized_root_forces_do_not_conflict_impl)
+
+def _namespace_without_root_uses_nonroot_dedup_impl(ctx):
+    env = unittest.begin(ctx)
+    higher = _artifact("2.0")
+
+    result = _layer(
+        root_present = False,
+        non_root_artifacts = {
+            "first": [_artifact("1.0")],
+            "second": [higher],
+        },
+    )
+
+    asserts.equals(env, [higher], result.artifacts)
+    asserts.equals(env, [], result.boms)
+    asserts.equals(env, [], result.diagnostics)
+
+    return unittest.end(env)
+
+namespace_without_root_uses_nonroot_dedup_test = unittest.make(_namespace_without_root_uses_nonroot_dedup_impl)
+
+def _known_contributors_filter_deps_and_boms_impl(ctx):
+    env = unittest.begin(ctx)
+    kept_artifact = _artifact("1.0")
+    kept_bom = _artifact("1.0", packaging = "pom", artifact = "bom")
+
+    artifacts = filter_known_contributing_modules(
+        "custom",
+        sets.make(["kept"]),
+        {"kept": [kept_artifact], "excluded-dep": [_artifact("2.0")]},
+        "deps",
+    )
+    boms = filter_known_contributing_modules(
+        "custom",
+        sets.make(["kept"]),
+        {"kept": [kept_bom], "excluded-bom": [_artifact("2.0", packaging = "pom", artifact = "bom")]},
+        "boms",
+    )
+
+    asserts.equals(env, {"kept": [kept_artifact]}, artifacts.filtered)
+    asserts.equals(env, {"kept": [kept_bom]}, boms.filtered)
+    asserts.equals(
+        env,
+        [struct(
+            text = "\nINFO: The @custom repo is not using deps from excluded-dep because it is not in the known_contributing_modules",
+            gate = "verbose",
+        )],
+        artifacts.diagnostics,
+    )
+    asserts.equals(
+        env,
+        [struct(
+            text = "\nINFO: The @custom repo is not using boms from excluded-bom because it is not in the known_contributing_modules",
+            gate = "verbose",
+        )],
+        boms.diagnostics,
+    )
+
+    return unittest.end(env)
+
+known_contributors_filter_deps_and_boms_test = unittest.make(_known_contributors_filter_deps_and_boms_impl)
+
+def _bom_only_contributor_warns_about_modules_impl(ctx):
+    env = unittest.begin(ctx)
+    bom = _artifact("1.0", packaging = "pom", artifact = "bom")
+
+    result = _layer(non_root_boms = {"dep": [bom]})
+
+    asserts.equals(env, [bom], result.boms)
+    asserts.equals(
+        env,
+        [struct(
+            text = "The maven repository 'maven' has contributions from multiple bzlmod modules, and will be resolved together: [\"dep\"].\nSee https://github.com/bazel-contrib/rules_jvm_external/blob/master/docs/bzlmod.md#module-dependency-layering for more information. \n To suppress this warning review the contributions from the other modules and add the following attribute in the root MODULE.bazel file: \nmaven.install(\n  known_contributing_modules = [\"dep\"],\n  ...\n)",
+            gate = "always",
+        ), struct(
+            text = "\nINFO: The @maven repo is getting the additional artifact com.example:bom:1.0 from the dep bazel dep.",
+            gate = "repin_verbose",
+        )],
+        result.diagnostics,
+    )
+
+    return unittest.end(env)
+
+bom_only_contributor_warns_about_modules_test = unittest.make(_bom_only_contributor_warns_about_modules_impl)
+
+def _boms_merge_with_root_priority_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0", packaging = "pom", artifact = "bom", force_version = True)
+
+    result = _layer(
+        root_boms = [root],
+        non_root_boms = {"dep": [_artifact("2.0", packaging = "pom", artifact = "bom")]},
+    )
+
+    asserts.equals(env, [root], result.boms)
+
+    return unittest.end(env)
+
+boms_merge_with_root_priority_test = unittest.make(_boms_merge_with_root_priority_impl)
+
+def _classifier_and_packaging_layer_independently_impl(ctx):
+    env = unittest.begin(ctx)
+    plain = _artifact("1.0")
+    classified = _artifact("2.0", classifier = "tests")
+    pom = _artifact("3.0", packaging = "pom")
+
+    result = _layer(
+        root_present = False,
+        non_root_artifacts = {"dep": [plain, classified, pom]},
+    )
+
+    asserts.equals(env, [plain, classified, pom], result.artifacts)
+
+    return unittest.end(env)
+
+classifier_and_packaging_layer_independently_test = unittest.make(_classifier_and_packaging_layer_independently_impl)
+
+def _pinned_gradle_root_beats_higher_nonroot_force_impl(ctx):
+    env = unittest.begin(ctx)
+    root = _artifact("1.0")
+
+    result = _layer(
+        root_artifacts = [root],
+        resolver = "gradle",
+        version_conflict_policy = "pinned",
+        non_root_artifacts = {"dep": [_artifact("2.0", force_version = True)]},
+    )
+
+    asserts.equals(env, ["1.0"], [artifact.version for artifact in result.artifacts])
+    asserts.true(env, result.artifacts[0].force_version)
+
+    return unittest.end(env)
+
+pinned_gradle_root_beats_higher_nonroot_force_test = unittest.make(_pinned_gradle_root_beats_higher_nonroot_force_impl)
+
+def _namespaces_are_layered_independently_impl(ctx):
+    env = unittest.begin(ctx)
+
+    first = _layer(
+        name = "first",
+        root_present = False,
+        non_root_artifacts = {"dep": [_artifact("1.0")]},
+    )
+    second = _layer(
+        name = "second",
+        root_present = False,
+        non_root_artifacts = {"dep": [_artifact("2.0")]},
+    )
+
+    asserts.equals(env, ["1.0"], [artifact.version for artifact in first.artifacts])
+    asserts.equals(env, ["2.0"], [artifact.version for artifact in second.artifacts])
+
+    return unittest.end(env)
+
+namespaces_are_layered_independently_test = unittest.make(_namespaces_are_layered_independently_impl)
+
+def _diagnostics_preserve_text_gates_and_order_impl(ctx):
+    env = unittest.begin(ctx)
+    root_artifact = _artifact("1.0")
+    root_bom = _artifact("1.0", packaging = "pom", artifact = "bom")
+    kept_artifacts = [
+        _artifact("2.0"),
+        _artifact("1.0", artifact = "additional"),
+    ]
+    kept_boms = [
+        _artifact("2.0", packaging = "pom", artifact = "bom"),
+        _artifact("1.0", packaging = "pom", artifact = "additional-bom"),
+    ]
+
+    result = _layer(
+        name = "custom",
+        root_artifacts = [root_artifact],
+        root_boms = [root_bom],
+        known_contributing_modules = sets.make(["kept"]),
+        non_root_artifacts = {
+            "kept": kept_artifacts,
+            "excluded-dep": [_artifact("3.0")],
+        },
+        non_root_boms = {
+            "kept": kept_boms,
+            "excluded-bom": [_artifact("3.0", packaging = "pom", artifact = "bom")],
+        },
+    )
+
+    asserts.equals(
+        env,
+        [
+            struct(text = "\nINFO: The @custom repo is not using deps from excluded-dep because it is not in the known_contributing_modules", gate = "verbose"),
+            struct(text = "\nINFO: The @custom repo is not using boms from excluded-bom because it is not in the known_contributing_modules", gate = "verbose"),
+            struct(text = "\nWARNING: For dependency 'com.example:library' the root @custom repo wants version 1.0, but got 2.0 from the kept bazel dep. Please update the version in your MODULE.bazel or set `force_version = True`.", gate = "always"),
+            struct(text = "\nINFO: The @custom repo is getting the additional artifact com.example:additional:1.0 from the kept bazel dep.", gate = "repin_verbose"),
+            struct(text = "\nWARNING: For dependency 'com.example:bom' the root @custom repo wants version 1.0, but got 2.0 from the kept bazel dep. Please update the version in your MODULE.bazel or set `force_version = True`.", gate = "always"),
+            struct(text = "\nINFO: The @custom repo is getting the additional artifact com.example:additional-bom:1.0 from the kept bazel dep.", gate = "repin_verbose"),
+        ],
+        result.diagnostics,
+    )
+
+    return unittest.end(env)
+
+diagnostics_preserve_text_gates_and_order_test = unittest.make(_diagnostics_preserve_text_gates_and_order_impl)
+
+def _default_namespace_contribution_warning_is_preserved_impl(ctx):
+    env = unittest.begin(ctx)
+
+    result = _layer(non_root_artifacts = {"dep": [_artifact("1.0")]})
+
+    asserts.equals(
+        env,
+        [struct(
+            text = "The maven repository 'maven' has contributions from multiple bzlmod modules, and will be resolved together: [\"dep\"].\nSee https://github.com/bazel-contrib/rules_jvm_external/blob/master/docs/bzlmod.md#module-dependency-layering for more information. \n To suppress this warning review the contributions from the other modules and add the following attribute in the root MODULE.bazel file: \nmaven.install(\n  known_contributing_modules = [\"dep\"],\n  ...\n)",
+            gate = "always",
+        ), struct(
+            text = "\nINFO: The @maven repo is getting the additional artifact com.example:library:1.0 from the dep bazel dep.",
+            gate = "repin_verbose",
+        )],
+        result.diagnostics,
+    )
+
+    return unittest.end(env)
+
+default_namespace_contribution_warning_is_preserved_test = unittest.make(_default_namespace_contribution_warning_is_preserved_impl)
+
+def _diagnostic_gates_match_environment_flags_impl(ctx):
+    env = unittest.begin(ctx)
+    diagnostics = {
+        gate: struct(text = gate, gate = gate)
+        for gate in ["always", "repin", "verbose", "repin_verbose"]
+    }
+
+    asserts.equals(
+        env,
+        [True, False, False, False],
+        [should_print_diagnostic(diagnostics[gate], False, False) for gate in diagnostics],
+    )
+    asserts.equals(
+        env,
+        [True, True, False, False],
+        [should_print_diagnostic(diagnostics[gate], True, False) for gate in diagnostics],
+    )
+    asserts.equals(
+        env,
+        [True, False, True, False],
+        [should_print_diagnostic(diagnostics[gate], False, True) for gate in diagnostics],
+    )
+    asserts.equals(
+        env,
+        [True, True, True, True],
+        [should_print_diagnostic(diagnostics[gate], True, True) for gate in diagnostics],
+    )
+
+    return unittest.end(env)
+
+diagnostic_gates_match_environment_flags_test = unittest.make(_diagnostic_gates_match_environment_flags_impl)
+
+def layering_test_suite():
+    conflicting_nonroot_forces_target(
+        name = "conflicting_nonroot_forces_target",
+        # This target must only be analysed through the expected-failure test.
+        tags = ["manual"],
+    )
+    nonroot_override_error_target(
+        name = "nonroot_override_error_target",
+        # This target must only be analysed through the expected-failure test.
+        tags = ["manual"],
+    )
+    unforced_root_with_conflicting_nonroot_forces_target(
+        name = "unforced_root_with_conflicting_nonroot_forces_target",
+        # This target must only be analysed through the expected-failure test.
+        tags = ["manual"],
+    )
+    same_module_forces_target(
+        name = "different_forced_versions_from_same_nonroot_module_target",
+        second_version = "2.0",
+        tags = ["manual"],
+    )
+    same_module_forces_target(
+        name = "different_forced_versions_from_root_module_target",
+        root = True,
+        second_version = "2.0",
+        tags = ["manual"],
+    )
+    unittest.suite(
+        "layering_tests",
+        partial.make(root_only_resolves_root_version_test, size = "small"),
+        partial.make(nonroot_lower_version_is_dropped_test, size = "small"),
+        partial.make(equal_unforced_nonroot_keeps_root_metadata_without_warning_test, size = "small"),
+        partial.make(nonroot_higher_version_wins_and_warns_test, size = "small"),
+        partial.make(nonroot_override_warning_can_be_disabled_test, size = "small"),
+        partial.make(higher_nonroot_force_beats_unforced_root_test, size = "small"),
+        partial.make(lower_nonroot_force_beats_unforced_root_test, size = "small"),
+        partial.make(equal_nonroot_force_retains_transitive_pin_test, size = "small"),
+        partial.make(both_forced_root_wins_test, size = "small"),
+        partial.make(root_force_beats_conflicting_nonroot_forces_test, size = "small"),
+        partial.make(single_nonroot_survives_test, size = "small"),
+        partial.make(testonly_nonroot_is_filtered_test, size = "small"),
+        partial.make(multiple_nonroot_highest_wins_test, size = "small"),
+        partial.make(equal_version_tie_keeps_first_module_metadata_test, size = "small"),
+        partial.make(root_force_beats_higher_unforced_nonroot_test, size = "small"),
+        partial.make(
+            conflicting_nonroot_forces_fail_test,
+            target_under_test = ":conflicting_nonroot_forces_target",
+        ),
+        partial.make(
+            unforced_root_with_conflicting_nonroot_forces_fail_test,
+            target_under_test = ":unforced_root_with_conflicting_nonroot_forces_target",
+        ),
+        partial.make(
+            nonroot_override_error_fails_test,
+            target_under_test = ":nonroot_override_error_target",
+        ),
+        partial.make(
+            different_forced_versions_from_same_nonroot_module_fail_test,
+            expected_message = "Module 'dep' forces dependency 'com.example:library' at different versions: 1.0 and 2.0.",
+            target_under_test = ":different_forced_versions_from_same_nonroot_module_target",
+        ),
+        partial.make(
+            different_forced_versions_from_root_module_fail_test,
+            expected_message = "Module 'root' forces dependency 'com.example:library' at different versions: 1.0 and 2.0.",
+            target_under_test = ":different_forced_versions_from_root_module_target",
+        ),
+        partial.make(matching_nonroot_forces_keep_first_module_test, size = "small"),
+        partial.make(matching_forced_duplicates_from_same_nonroot_module_keep_first_test, size = "small"),
+        partial.make(matching_forced_duplicates_from_root_module_remain_for_repository_check_test, size = "small"),
+        partial.make(forced_nonroot_beats_higher_unforced_nonroot_test, size = "small"),
+        partial.make(forced_nonroot_beats_higher_duplicate_from_same_module_test, size = "small"),
+        partial.make(forced_classifiers_from_same_module_layer_independently_test, size = "small"),
+        partial.make(pinned_maven_synthesized_root_forces_do_not_conflict_test, size = "small"),
+        partial.make(namespace_without_root_uses_nonroot_dedup_test, size = "small"),
+        partial.make(known_contributors_filter_deps_and_boms_test, size = "small"),
+        partial.make(bom_only_contributor_warns_about_modules_test, size = "small"),
+        partial.make(boms_merge_with_root_priority_test, size = "small"),
+        partial.make(classifier_and_packaging_layer_independently_test, size = "small"),
+        partial.make(pinned_gradle_root_beats_higher_nonroot_force_test, size = "small"),
+        partial.make(namespaces_are_layered_independently_test, size = "small"),
+        partial.make(diagnostics_preserve_text_gates_and_order_test, size = "small"),
+        partial.make(default_namespace_contribution_warning_is_preserved_test, size = "small"),
+        partial.make(diagnostic_gates_match_environment_flags_test, size = "small"),
+    )
diff --git a/tests/unit/version_conflict_policy_test.bzl b/tests/unit/version_conflict_policy_test.bzl
index b4210a9..891a434 100644
--- a/tests/unit/version_conflict_policy_test.bzl
+++ b/tests/unit/version_conflict_policy_test.bzl
@@ -2,8 +2,8 @@
 
 load("@bazel_skylib//lib:partial.bzl", "partial")
 load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
-load("//private/extensions:maven.bzl", "apply_root_version_conflict_policy")
 load("//private/lib:coordinates.bzl", "unpack_coordinates")
+load("//private/lib:layering.bzl", "apply_root_version_conflict_policy")
 
 def _pinned_policy_forces_versioned_root_artifacts_impl(ctx):
     env = unittest.begin(ctx)