diff --git a/cli/BUILD b/cli/BUILD index ee701a5..7cf9982 100644 --- a/cli/BUILD +++ b/cli/BUILD @@ -72,6 +72,12 @@ kt_jvm_test( runtime_deps = [":cli-test-lib"], ) +kt_jvm_test( + name = "CalculateImpactedTargetsInteractorModuleQueryTest", + test_class = "com.bazel_diff.interactor.CalculateImpactedTargetsInteractorModuleQueryTest", + runtime_deps = [":cli-test-lib"], +) + kt_jvm_test( name = "NormalisingPathConverterTest", test_class = "com.bazel_diff.cli.converter.NormalisingPathConverterTest", diff --git a/cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt b/cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt index 20dee6c..41473f6 100644 --- a/cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt +++ b/cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt @@ -57,7 +57,10 @@ class ModuleGraphParser { val version = obj.get("version")?.asString val apparentName = obj.get("apparentName")?.asString - if (key != null && name != null && version != null && apparentName != null) { + // Bazel's MODULE.bazel spec requires module names to be non-empty; reject + // empty `name` so the synthetic `` entry of an unnamed MODULE.bazel + // doesn't reach downstream canonical-repo matching. + if (key != null && !name.isNullOrEmpty() && version != null && apparentName != null) { modules[key] = Module(key, name, version, apparentName) } diff --git a/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt b/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt index d1f5762..dfc0bea 100644 --- a/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt +++ b/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt @@ -1,6 +1,7 @@ package com.bazel_diff.interactor import com.bazel_diff.bazel.BazelQueryService +import com.bazel_diff.bazel.Module import com.bazel_diff.bazel.ModuleGraphParser import com.bazel_diff.hash.TargetHash import com.bazel_diff.log.Logger @@ -50,7 +51,7 @@ class CalculateImpactedTargetsInteractor : KoinComponent { val impactedTargets = if (changedModules.isNotEmpty()) { logger.i { "Module changes detected - querying for targets that depend on changed modules" } - queryTargetsDependingOnModules(changedModules, to) + queryTargetsDependingOnModules(changedModules, from, to) } else { computeSimpleImpactedTargets(from, to) } @@ -103,7 +104,7 @@ class CalculateImpactedTargetsInteractor : KoinComponent { val impactedTargets = if (changedModules.isNotEmpty()) { logger.i { "Module changes detected - querying for targets that depend on changed modules" } - val moduleImpactedTargets = queryTargetsDependingOnModules(changedModules, to) + val moduleImpactedTargets = queryTargetsDependingOnModules(changedModules, from, to) // Mark module-impacted targets with distance 0, then compute distances from there val moduleImpactedHashes = from.filterKeys { !moduleImpactedTargets.contains(it) } computeAllDistances(moduleImpactedHashes, to, depEdges) @@ -259,56 +260,52 @@ class CalculateImpactedTargetsInteractor : KoinComponent { } /** - * Detects module changes by comparing module graphs and returns changed module keys. + * Detects module changes by comparing module graphs and returns the changed Modules. * - * This method: - * 1. Parses the from and to module graphs - * 2. Identifies which modules changed (added, removed, or version changed) - * 3. Logs the changes for visibility - * 4. Returns the set of changed module keys + * Resolves each changed key against the "to" graph first (the state we will query + * against), falling back to the "from" graph for modules that were removed. * - * @param fromModuleGraphJson JSON from `bazel mod graph --output=json` for starting revision - * @param toModuleGraphJson JSON from `bazel mod graph --output=json` for final revision - * @return Set of changed module keys, empty if no changes + * @param fromModuleGraphJson JSON from `bazel mod graph --output=json` for the starting revision + * @param toModuleGraphJson JSON from `bazel mod graph --output=json` for the final revision + * @return Set of changed Modules, empty if no changes */ private fun detectChangedModules( fromModuleGraphJson: String?, toModuleGraphJson: String? - ): Set { - // If either module graph is missing, assume no changes + ): Set { if (fromModuleGraphJson == null || toModuleGraphJson == null) { return emptySet() } - // Parse module graphs val fromGraph = moduleGraphParser.parseModuleGraph(fromModuleGraphJson) val toGraph = moduleGraphParser.parseModuleGraph(toModuleGraphJson) + val changedKeys = moduleGraphParser.findChangedModules(fromGraph, toGraph) - // Find changed modules - val changedModules = moduleGraphParser.findChangedModules(fromGraph, toGraph) - - if (changedModules.isEmpty()) { + if (changedKeys.isEmpty()) { logger.i { "No module changes detected" } - } else { - logger.i { "Detected ${changedModules.size} module changes: ${changedModules.joinToString(", ")}" } + return emptySet() } + val changedModules = changedKeys.mapNotNull { key -> toGraph[key] ?: fromGraph[key] }.toSet() + logger.i { "Detected ${changedModules.size} module changes: ${changedModules.joinToString(", ") { it.key }}" } return changedModules } /** * Queries Bazel to find all workspace targets that depend on any changed module. * - * Maps every changed module to its matching bzlmod canonical repos, then issues a - * single `rdeps(//..., @@a//... + @@b//... + ...)` query. Bazel executes the union - * in one analysis pass, avoiding per-repo subprocess fan-out. + * Resolves each changed module to its bzlmod canonical repos by name-prefix match + * (R belongs to M if R starts with `"{M.name}+"` or `"{M.name}~"`; canonical names + * are deterministic from the module name and extension-created `name++ext+repo` / + * `name~~ext~repo` forms are subsumed), then issues a single unioned + * `rdeps(//..., @@a//... + @@b//... + ...)` query. * - * @param changedModuleKeys Set of changed module keys (e.g., "abseil-cpp@20240722.0") - * @param allTargets Map of all targets from the final revision - * @return Set of target labels that are impacted by module changes + * The hash-diff over `from`/`allTargets` is unioned in below to surface labels + * whose content changed alongside a MODULE.bazel update. */ private fun queryTargetsDependingOnModules( - changedModuleKeys: Set, + changedModules: Set, + from: Map, allTargets: Map ): Set { val queryService: BazelQueryService? = try { @@ -322,48 +319,70 @@ class CalculateImpactedTargetsInteractor : KoinComponent { return allTargets.keys } - // Map every changed module to its matching bzlmod canonical repos. A single module - // name can match multiple canonical repos (e.g. rules_jvm_external matches - // rules_jvm_external~~maven~maven, rules_jvm_external~~toolchains~...). Log per - // module so an operator can attribute a pathologically large impacted set back to - // a specific module bump. + val canonicalRepos: Set = allTargets.keys.asSequence() + .filter { it.startsWith("@@") } + .map { it.substring(2).substringBefore("//") } + .filter { it.isNotEmpty() } + .toSet() + val moduleRepos = mutableSetOf() - for (moduleKey in changedModuleKeys) { - val moduleName = moduleKey.substringBefore("@") - val matched = allTargets.keys - .filter { it.startsWith("@@") && it.contains(moduleName) } - .map { it.substring(2).substringBefore("//") } - if (matched.isEmpty()) { - logger.w { "No external repository matched module $moduleKey" } - } else { - logger.i { "Module $moduleKey matched ${matched.size} repos: ${matched.joinToString(", ")}" } - moduleRepos.addAll(matched) + var skippedNoMatch = 0 + for (module in changedModules) { + logger.i { "Resolving repos for changed module: ${module.name} (key: ${module.key})" } + val resolved = reposOwnedBy(module, canonicalRepos) + if (resolved.isEmpty()) { + logger.i { "No external repository found for module ${module.name} (skipped)" } + skippedNoMatch++ + continue } + logger.i { "Found ${resolved.size} repositories for module ${module.name}: ${resolved.joinToString(", ")}" } + moduleRepos.addAll(resolved) + } + if (skippedNoMatch > 0) { + logger.i { "Skipped $skippedNoMatch of ${changedModules.size} changed modules with no materialised repos in allTargets" } } if (moduleRepos.isEmpty()) { logger.i { "No external repositories matched any changed module" } - return computeSimpleImpactedTargets(emptyMap(), allTargets) + return computeSimpleImpactedTargets(from, allTargets) } - logger.i { "Querying rdeps for ${moduleRepos.size} repositories across ${changedModuleKeys.size} changed modules" } + logger.i { "Querying rdeps for ${moduleRepos.size} repositories across ${changedModules.size} changed modules" } val impactedTargets = mutableSetOf() try { - // Single unioned rdeps query: bazel executes the union in one analysis pass. val queryExpression = "rdeps(//..., ${moduleRepos.joinToString(" + ") { "@@$it//..." }})" val rdeps = runBlocking { queryService.query(queryExpression, useCquery = false) } val rdepLabels = rdeps.map { it.name }.filter { !it.startsWith("@@") } logger.i { "Found ${rdepLabels.size} workspace targets depending on changed modules" } impactedTargets.addAll(rdepLabels) } catch (e: Exception) { - logger.e(e) { "Unioned rdeps query failed - conservatively marking all workspace targets impacted" } - impactedTargets.addAll(allTargets.keys.filter { !it.startsWith("@@") }) + logger.e(e) { "Unioned rdeps query failed - falling back to buildable workspace targets (or every hashed label on bzlmod-only shapes)" } + val buildableWorkspaceTargets = allTargets.keys.filter(::isBuildableWorkspaceTarget) + impactedTargets.addAll( + if (buildableWorkspaceTargets.isEmpty()) allTargets.keys + else buildableWorkspaceTargets + ) } - impactedTargets.addAll(computeSimpleImpactedTargets(emptyMap(), allTargets)) + // Union with hash-diff results to surface labels whose content changed alongside + // the MODULE.bazel update. + impactedTargets.addAll(computeSimpleImpactedTargets(from, allTargets)) logger.i { "Total targets impacted by module changes: ${impactedTargets.size}" } return impactedTargets } + + private fun reposOwnedBy(module: Module, canonicalRepos: Set): Set { + val prefixes = listOf("${module.name}+", "${module.name}~") + return canonicalRepos.filter { repo -> + prefixes.any { repo.startsWith(it) } + }.toSet() + } + + // Distinct from the `excludeExternalTargets` filter at the output sites: this + // also strips `@@` so the rdeps-failure fallback can detect a bzlmod-only + // workspace shape. + private fun isBuildableWorkspaceTarget(label: String): Boolean = + !label.startsWith("@@") && !label.startsWith("//external:") } diff --git a/cli/src/test/kotlin/com/bazel_diff/bazel/ModuleGraphParserTest.kt b/cli/src/test/kotlin/com/bazel_diff/bazel/ModuleGraphParserTest.kt index 192847b..aa5a749 100644 --- a/cli/src/test/kotlin/com/bazel_diff/bazel/ModuleGraphParserTest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/bazel/ModuleGraphParserTest.kt @@ -6,6 +6,7 @@ import assertk.assertions.hasSize import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isNotNull +import assertk.assertions.isNull import org.junit.Test class ModuleGraphParserTest { @@ -132,6 +133,31 @@ class ModuleGraphParserTest { assertThat(result).isEmpty() } + @Test + fun parseModuleGraph_withEmptyName_skipsModule() { + // Reproduces the JSON shape `bazel mod graph --output=json` emits for an + // unnamed root MODULE.bazel. + val json = + """ + { + "key": "", + "name": "", + "version": "", + "apparentName": "", + "dependencies": [ + {"key": "platforms@1.0.0", "name": "platforms", "version": "1.0.0", "apparentName": "platforms"} + ] + } + """ + .trimIndent() + + val result = parser.parseModuleGraph(json) + + assertThat(result).hasSize(1) + assertThat(result["platforms@1.0.0"]).isNotNull() + assertThat(result[""]).isNull() + } + @Test fun parseModuleGraph_withIncompleteModule_skipsModule() { val json = diff --git a/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorModuleQueryTest.kt b/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorModuleQueryTest.kt new file mode 100644 index 0000000..0216cbb --- /dev/null +++ b/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorModuleQueryTest.kt @@ -0,0 +1,372 @@ +package com.bazel_diff.interactor + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.doesNotContain +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import com.bazel_diff.SilentLogger +import com.bazel_diff.bazel.BazelQueryService +import com.bazel_diff.bazel.BazelTarget +import com.bazel_diff.hash.TargetHash +import com.bazel_diff.log.Logger +import com.google.gson.GsonBuilder +import java.io.StringWriter +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Tests for the module-change query path in [CalculateImpactedTargetsInteractor] that + * exercise the predicate matching changed modules to canonical external repos in + * `allTargets`. Lives in its own test class (instead of extending + * `CalculateImpactedTargetsInteractorTest`) so we can register a mocked + * [BazelQueryService] in Koin without disturbing the existing global `KoinTestRule`. + */ +class CalculateImpactedTargetsInteractorModuleQueryTest : KoinTest { + + private val queryService: BazelQueryService = mock() + + @Before + fun setUp() { + startKoin { + modules( + module { + single { SilentLogger } + single { queryService } + single { GsonBuilder().disableHtmlEscaping().create() } + }) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun matchesCanonicalPlusFormRepo() { runBlocking { + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@aspect_bazel_lib+//lib:foo" to TargetHash("Rule", "x", "x"), + "@@other+//x:y" to TargetHash("Rule", "o", "o")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + val writer = StringWriter() + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = writer, + targetTypes = null, + fromModuleGraphJson = graph("aspect_bazel_lib", "2.10.0"), + toModuleGraphJson = graph("aspect_bazel_lib", "2.11.0")) + + verify(queryService).query(eq("rdeps(//..., @@aspect_bazel_lib+//...)"), eq(false)) + verify(queryService, never()).query(eq("rdeps(//..., @@other+//...)"), eq(false)) + // Sentinel: with rdeps empty and from == to, the union must be empty too. + // Catches a regression where the hash-diff union widens to allTargets.keys. + assertThat(outputLines(writer)).isEmpty() + } } + + @Test + fun matchesCanonicalTildeFormRepo() { runBlocking { + val start = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@abseil-cpp~20240116.2//absl:strings" to TargetHash("Rule", "x", "x")) + val end = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@abseil-cpp~20240722.0//absl:strings" to TargetHash("Rule", "y", "y")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + CalculateImpactedTargetsInteractor().execute( + from = start, + to = end, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = graph("abseil-cpp", "20240116.2"), + toModuleGraphJson = graph("abseil-cpp", "20240722.0")) + + verify(queryService).query(eq("rdeps(//..., @@abseil-cpp~20240722.0//...)"), eq(false)) + } } + + @Test + fun matchesExtensionCreatedRepo() { runBlocking { + // Extension-created repos use canonical forms `name++ext+repo` / + // `name~~ext~repo`. Both are subsumed by the `name+`/`name~` prefixes. + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@rules_jvm_external+//lib:foo" to TargetHash("Rule", "x", "x"), + "@@rules_jvm_external++maven+maven//:guava" to TargetHash("Rule", "y", "y")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = graph("rules_jvm_external", "5.0"), + toModuleGraphJson = graph("rules_jvm_external", "6.0")) + + val queryCaptor = argumentCaptor() + verify(queryService).query(queryCaptor.capture(), eq(false)) + val unioned = queryCaptor.firstValue + assertThat(unioned).contains("@@rules_jvm_external+//...") + assertThat(unioned).contains("@@rules_jvm_external++maven+maven//...") + } } + + @Test + fun doesNotMatchUnrelatedRepoBySubstring() { runBlocking { + // Module "cpp" must not match canonical "abseil-cpp+". + val from = mapOf( + "//app:app" to TargetHash("Rule", "a1", "a1"), + "@@abseil-cpp+//absl:strings" to TargetHash("Rule", "b", "b"), + "@@cpp+//pkg:lib" to TargetHash("Rule", "c", "c")) + val to = mapOf( + "//app:app" to TargetHash("Rule", "a2", "a2"), // hash change + "@@abseil-cpp+//absl:strings" to TargetHash("Rule", "b", "b"), + "@@cpp+//pkg:lib" to TargetHash("Rule", "c", "c")) + + whenever(queryService.query(any(), any())).thenAnswer { inv -> + if (inv.getArgument(0) == "rdeps(//..., @@cpp+//...)") { + listOf(mockRuleTarget("//foo:bar")) + } else emptyList() + } + + val writer = StringWriter() + CalculateImpactedTargetsInteractor().execute( + from = from, + to = to, + outputWriter = writer, + targetTypes = null, + fromModuleGraphJson = graph("cpp", "1.0"), + toModuleGraphJson = graph("cpp", "2.0")) + + val queryCaptor = argumentCaptor() + verify(queryService).query(queryCaptor.capture(), any()) + assertThat(queryCaptor.allValues).hasSize(1) + assertThat(queryCaptor.allValues).contains("rdeps(//..., @@cpp+//...)") + assertThat(queryCaptor.allValues).doesNotContain("rdeps(//..., @@abseil-cpp+//...)") + assertThat(outputLines(writer)).containsExactlyInAnyOrder("//foo:bar", "//app:app") + } } + + @Test + fun transitiveModuleMatchedByNamePrefix() { runBlocking { + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@deep_transitive_dep~3.2.1//:lib" to TargetHash("Rule", "b", "b")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = graph("deep_transitive_dep", "3.2.0"), + toModuleGraphJson = graph("deep_transitive_dep", "3.2.1")) + + verify(queryService).query( + eq("rdeps(//..., @@deep_transitive_dep~3.2.1//...)"), eq(false)) + } } + + @Test + fun moduleWithNoMaterialisedReposIsNotQueried() { runBlocking { + // `ghost_module` has no canonical prefix anywhere in allTargets — no + // rdeps subprocess should spawn for this module. + val hashes = mapOf("//app:app" to TargetHash("Rule", "a", "a")) + + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = graph("ghost_module", "1.0"), + toModuleGraphJson = graph("ghost_module", "2.0")) + + verify(queryService, never()).query(any(), any()) + } } + + @Test + fun multipleChangedModulesProduceDisjointMatchSets() { runBlocking { + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@abseil-cpp+//a:1" to TargetHash("Rule", "b", "b"), + "@@aspect_bazel_lib+//b:2" to TargetHash("Rule", "c", "c")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + val fromGraph = """ + { + "key": "root", "name": "root", "version": "", "apparentName": "root", + "dependencies": [ + {"key": "abseil-cpp@1.0", "name": "abseil-cpp", "version": "1.0", "apparentName": "abseil-cpp"}, + {"key": "aspect_bazel_lib@2.0", "name": "aspect_bazel_lib", "version": "2.0", "apparentName": "aspect_bazel_lib"} + ] + } + """.trimIndent() + val toGraph = """ + { + "key": "root", "name": "root", "version": "", "apparentName": "root", + "dependencies": [ + {"key": "abseil-cpp@2.0", "name": "abseil-cpp", "version": "2.0", "apparentName": "abseil-cpp"}, + {"key": "aspect_bazel_lib@3.0", "name": "aspect_bazel_lib", "version": "3.0", "apparentName": "aspect_bazel_lib"} + ] + } + """.trimIndent() + + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = fromGraph, + toModuleGraphJson = toGraph) + + val queryCaptor = argumentCaptor() + verify(queryService).query(queryCaptor.capture(), any()) + val unioned = queryCaptor.firstValue + assertThat(unioned).contains("@@abseil-cpp+//...") + assertThat(unioned).contains("@@aspect_bazel_lib+//...") + } } + + @Test + fun versionBumpReportedAsAddPlusRemoveDedupesCanonical() { runBlocking { + // findChangedModules reports a version bump as {old removed, new added}. + // Both Modules share the canonical prefix; the union must contain it once. + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@foo+//pkg:lib" to TargetHash("Rule", "x", "x")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = StringWriter(), + targetTypes = null, + fromModuleGraphJson = graph("foo", "1.0"), + toModuleGraphJson = graph("foo", "2.0")) + + val queryCaptor = argumentCaptor() + verify(queryService).query(queryCaptor.capture(), eq(false)) + val unioned = queryCaptor.firstValue + assertThat(unioned.split("@@foo+//...")).hasSize(2) // exactly one occurrence + } } + + @Test + fun queryFailureOnBzlmodOnlyShapeEmitsAllHashedLabels() { runBlocking { + // No workspace-local `//...` labels, so the fallback must surface the + // full hash set. Otherwise the downstream `excludeExternalTargets` + // strip reduces it to empty. + val hashes = mapOf( + "@@abseil-cpp+//absl:strings" to TargetHash("Rule", "a", "a"), + "@@abseil-cpp+//absl:base" to TargetHash("Rule", "b", "b"), + "//external:com_google_absl" to TargetHash("Rule", "c", "c")) + + whenever(queryService.query(any(), any())) + .thenThrow(RuntimeException("simulated bazel query failure")) + + val writer = StringWriter() + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = writer, + targetTypes = null, + fromModuleGraphJson = graph("abseil-cpp", "20240116.2"), + toModuleGraphJson = graph("abseil-cpp", "20240722.0")) + + verify(queryService).query(eq("rdeps(//..., @@abseil-cpp+//...)"), eq(false)) + assertThat(outputLines(writer)).containsExactlyInAnyOrder( + "@@abseil-cpp+//absl:strings", + "@@abseil-cpp+//absl:base", + "//external:com_google_absl") + } } + + @Test + fun queryFailureOnMixedWorkspacePreservesGranularity() { runBlocking { + // Fallback must return only the buildable `//...` subset when one + // exists, not every hashed label. + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "//lib:util" to TargetHash("Rule", "b", "b"), + "@@abseil-cpp+//absl:strings" to TargetHash("Rule", "c", "c"), + "@@other+//x:y" to TargetHash("Rule", "d", "d"), + "//external:abseil-cpp" to TargetHash("Rule", "e", "e")) + + whenever(queryService.query(any(), any())) + .thenThrow(RuntimeException("simulated bazel query failure")) + + val writer = StringWriter() + CalculateImpactedTargetsInteractor().execute( + from = hashes, + to = hashes, + outputWriter = writer, + targetTypes = null, + fromModuleGraphJson = graph("abseil-cpp", "20240116.2"), + toModuleGraphJson = graph("abseil-cpp", "20240722.0")) + + assertThat(outputLines(writer)).containsExactlyInAnyOrder( + "//app:app", "//lib:util") + } } + + @Test + fun executeWithDistancesRunsModuleQueryPath() { runBlocking { + // Covers the module-query call site in `executeWithDistances`. + val hashes = mapOf( + "//app:app" to TargetHash("Rule", "a", "a"), + "@@aspect_bazel_lib+//lib:foo" to TargetHash("Rule", "x", "x")) + + whenever(queryService.query(any(), any())).thenReturn(emptyList()) + + val writer = StringWriter() + CalculateImpactedTargetsInteractor().executeWithDistances( + from = hashes, + to = hashes, + depEdges = emptyMap(), + outputWriter = writer, + targetTypes = null, + fromModuleGraphJson = graph("aspect_bazel_lib", "2.10.0"), + toModuleGraphJson = graph("aspect_bazel_lib", "2.11.0")) + + verify(queryService).query(eq("rdeps(//..., @@aspect_bazel_lib+//...)"), eq(false)) + assertThat(writer.toString()).isEqualTo("[]") + } } + + private fun outputLines(writer: StringWriter): List = + writer.toString().lineSequence().filter { it.isNotBlank() }.toList() + + private fun mockRuleTarget(name: String): BazelTarget.Rule { + val target = mock() + whenever(target.name).thenReturn(name) + return target + } + + private fun graph(name: String, version: String): String = """ + { + "key": "root", + "name": "root", + "version": "", + "apparentName": "root", + "dependencies": [ + {"key": "$name@$version", "name": "$name", "version": "$version", "apparentName": "$name"} + ] + } + """.trimIndent() +}