Make `amend_artifact` also match BOMs (#1586)

`amend_artifact` previously searched only the `artifacts` list, so a BOM
(declared via `install(boms = ...)` or split out of a `from_toml`
`libs.versions.toml` via `bom_modules`) could not be amended. The
amendment failed with "No artifact found matching coordinates" because
BOMs are tracked in a separate list that the amendment never searched.

`amend_artifact` now searches both the artifacts and boms lists, so a
BOM's version and other properties can be adjusted through the same
mechanism as a regular artifact (for example, force-pinning a BOM
version so transitive deps can't silently upgrade it).

Add unit tests covering amendment of artifacts, BOMs, and the
no-match case.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl
index 3ec6618..6c23e52 100644
--- a/private/extensions/maven.bzl
+++ b/private/extensions/maven.bzl
@@ -314,6 +314,30 @@
     return (artifact.group == coords.group and
             artifact.artifact == coords.artifact)
 
+def apply_amendment(amend, artifacts, boms):
+    """Applies an `amend_artifact` to matching entries in `artifacts` and `boms`.
+
+    Matching is by `group:artifact`, and matched entries are amended in place.
+    Both lists are searched: a BOM declared via `install(boms = ...)` or
+    `from_toml` can be amended through the same mechanism as a regular
+    artifact.
+
+    Args:
+        amend: The `amend_artifact` tag whose values should be applied.
+        artifacts: List of artifact structs to amend in place.
+        boms: List of BOM structs to amend in place.
+
+    Returns:
+        `True` if at least one artifact or BOM matched the amendment.
+    """
+    amended = False
+    for collection in (artifacts, boms):
+        for i, entry in enumerate(collection):
+            if _coordinates_match(entry, amend.coordinates):
+                collection[i] = _amend_artifact(entry, amend)
+                amended = True
+    return amended
+
 def process_gradle_versions_file(parsed, bom_modules):
     artifacts = []
     boms = []
@@ -526,19 +550,17 @@
             repo = target_repos.get(amend.name, {})
             if mod.is_root:
                 artifacts = repo.get("artifacts", [])
+                boms = repo.get("boms", [])
             else:
                 if not "bazel_dep_to_artifacts" in repo:
                     repo["bazel_dep_to_artifacts"] = {}
                 artifacts = repo["bazel_dep_to_artifacts"].get(mod.name, [])
+                if not "bazel_dep_to_boms" in repo:
+                    repo["bazel_dep_to_boms"] = {}
+                boms = repo["bazel_dep_to_boms"].get(mod.name, [])
 
-            # Find matching artifacts and amend them
-            amended = False
-            for i, artifact in enumerate(artifacts):
-                if _coordinates_match(artifact, amend.coordinates):
-                    artifacts[i] = _amend_artifact(artifact, amend)
-                    amended = True
-
-            if not amended:
+            # Amend matching artifacts and BOMs.
+            if not apply_amendment(amend, artifacts, boms):
                 # If no matching artifact found, this might be an error or we could create a placeholder
                 fail("No artifact found matching coordinates '%s' for amendment" % amend.coordinates)
 
diff --git a/tests/unit/BUILD b/tests/unit/BUILD
index 4144fce..f3a6e1d 100644
--- a/tests/unit/BUILD
+++ b/tests/unit/BUILD
@@ -1,3 +1,4 @@
+load(":amend_artifact_test.bzl", "amend_artifact_test_suite")
 load(":artifact_utilities_test.bzl", "artifact_utilities_test_suite")
 load(":coordinates_test.bzl", "coordinates_test_suite")
 load(":coursier_test.bzl", "coursier_test_suite")
@@ -8,6 +9,8 @@
 load(":specs_test.bzl", "artifact_specs_test_suite")
 load(":version_catalogs_test.bzl", "version_catalogs_test_suite")
 
+amend_artifact_test_suite()
+
 artifact_specs_test_suite()
 
 artifact_utilities_test_suite()
diff --git a/tests/unit/amend_artifact_test.bzl b/tests/unit/amend_artifact_test.bzl
new file mode 100644
index 0000000..88edc9c
--- /dev/null
+++ b/tests/unit/amend_artifact_test.bzl
@@ -0,0 +1,94 @@
+"""Tests for `amend_artifact` matching both artifacts and BOMs."""
+
+load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
+load("//private/extensions:maven.bzl", "apply_amendment")
+load("//private/lib:coordinates.bzl", "unpack_coordinates")
+
+def _amend(coordinates, force_version = None, neverlink = None, testonly = None, exclusions = None):
+    """Builds a struct mirroring an `amend_artifact` tag."""
+    return struct(
+        coordinates = coordinates,
+        force_version = force_version,
+        neverlink = neverlink,
+        testonly = testonly,
+        exclusions = exclusions,
+    )
+
+def _amends_artifact_impl(ctx):
+    env = unittest.begin(ctx)
+
+    artifacts = [unpack_coordinates("com.google.guava:guava:31.1-jre")]
+    boms = []
+
+    matched = apply_amendment(_amend("com.google.guava:guava", testonly = "true"), artifacts, boms)
+
+    asserts.true(env, matched)
+    asserts.equals(env, True, artifacts[0].testonly)
+    asserts.equals(env, "31.1-jre", artifacts[0].version)
+
+    return unittest.end(env)
+
+amends_artifact_test = unittest.make(_amends_artifact_impl)
+
+def _amends_bom_impl(ctx):
+    env = unittest.begin(ctx)
+
+    artifacts = []
+    boms = [unpack_coordinates("com.google.protobuf:protobuf-bom:3.25.5")]
+
+    matched = apply_amendment(_amend("com.google.protobuf:protobuf-bom", force_version = "on"), artifacts, boms)
+
+    asserts.true(env, matched)
+    asserts.equals(env, "com.google.protobuf", boms[0].group)
+    asserts.equals(env, "protobuf-bom", boms[0].artifact)
+    asserts.equals(env, True, boms[0].force_version)
+
+    # Amending the BOM must not change its declared version.
+    asserts.equals(env, "3.25.5", boms[0].version)
+
+    return unittest.end(env)
+
+amends_bom_test = unittest.make(_amends_bom_impl)
+
+def _amends_bom_alongside_artifacts_impl(ctx):
+    env = unittest.begin(ctx)
+
+    artifacts = [unpack_coordinates("com.google.guava:guava:31.1-jre")]
+    boms = [unpack_coordinates("com.google.protobuf:protobuf-bom:3.25.5")]
+
+    matched = apply_amendment(_amend("com.google.protobuf:protobuf-bom", force_version = "on"), artifacts, boms)
+
+    asserts.true(env, matched)
+    asserts.equals(env, True, boms[0].force_version)
+
+    # The artifact that wasn't targeted is left untouched: a raw unpacked
+    # artifact has no `force_version` field, whereas an amended one always does.
+    asserts.false(env, hasattr(artifacts[0], "force_version"))
+
+    return unittest.end(env)
+
+amends_bom_alongside_artifacts_test = unittest.make(_amends_bom_alongside_artifacts_impl)
+
+def _no_match_returns_false_impl(ctx):
+    env = unittest.begin(ctx)
+
+    artifacts = [unpack_coordinates("com.google.guava:guava:31.1-jre")]
+    boms = [unpack_coordinates("com.google.protobuf:protobuf-bom:3.25.5")]
+
+    matched = apply_amendment(_amend("org.example:does-not-exist", force_version = "on"), artifacts, boms)
+
+    # The caller relies on this being False to fail the build with a helpful message.
+    asserts.false(env, matched)
+
+    return unittest.end(env)
+
+no_match_returns_false_test = unittest.make(_no_match_returns_false_impl)
+
+def amend_artifact_test_suite():
+    unittest.suite(
+        "amend_artifact_tests",
+        amends_artifact_test,
+        amends_bom_test,
+        amends_bom_alongside_artifacts_test,
+        no_match_returns_false_test,
+    )