From 49caeedd2a13b995acb699361419243a1b9bba7a Mon Sep 17 00:00:00 2001 From: vedanthvasudev Date: Wed, 22 Apr 2026 10:31:40 +0100 Subject: [PATCH] feat/v2-explain: Add --explain task flag and ActionSource decision-trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators landing on a surprising affected-tests outcome (full suite when they expected a selection, or a skip when they expected a run) had no in-band way to ask "why?" — the engine's reasoning lived in debug logs that CI runners do not capture by default. This change adds an `--explain` flag to the `affectedTest` task that prints the full decision trace and exits without dispatching tests, alongside an `ActionSource` enum on `AffectedTestsConfig` so the trace can name which tier of the priority ladder (explicit `onXxx`, legacy boolean, mode default, pre-v2 hardcoded) picked each situation's action. The trace now surfaces the per-bucket file breakdown, the resolved situation + action, the action's source tier, and the full per-situation matrix on every run — closing the "why did my explicit setting not win?" debugging loop without requiring a code-spelunking session. The renderer is a pure function over `AffectedTestsConfig` and `AffectedTestsResult` so it is exercised by unit tests instead of needing a Gradle test runtime, and the engine's `AffectedTestsResult` now carries a `Buckets` record so explain output stays consistent with whatever the engine actually saw. README is updated with a sample trace and the new step in the Quick Start. --- README.md | 37 +++ .../core/AffectedTestsEngine.java | 66 +++- .../core/config/ActionSource.java | 42 +++ .../core/config/AffectedTestsConfig.java | 114 +++++-- .../core/config/AffectedTestsConfigTest.java | 47 +++ .../gradle/AffectedTestTask.java | 155 +++++++++ .../AffectedTestTaskExplainFormatTest.java | 296 ++++++++++++++++++ .../gradle/AffectedTestTaskLogFormatTest.java | 9 + 8 files changed, 727 insertions(+), 39 deletions(-) create mode 100644 affected-tests-core/src/main/java/io/affectedtests/core/config/ActionSource.java create mode 100644 affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskExplainFormatTest.java diff --git a/README.md b/README.md index 6edec09..ff9773e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,43 @@ plugins { ./gradlew affectedTest ``` +### 3. (Optional) Explain the decision + +```bash +./gradlew affectedTest --explain +``` + +Prints the full decision trace — bucket counts, situation, action, and the tier of the priority ladder (explicit / legacy / mode / hardcoded) that picked each action — without running a single test. Useful when a CI run escalated to the full suite and the operator needs to know *why* before filing a bug. + +Sample output: + +``` +=== Affected Tests — decision trace (--explain) === +Base ref: origin/master +Mode: unset (effective: n/a (pre-v2 defaults)) +Changed files: 3 +Buckets: + ignored 1 + out-of-scope 0 + production .java 1 + test .java 0 + unmapped 1 + ignored sample: README.md + production sample: src/main/java/com/example/Foo.java + unmapped sample: build.gradle +Situation: UNMAPPED_FILE +Action: FULL_SUITE (source: pre-v2 hardcoded default) +Outcome: FULL_SUITE — runAllOnNonJavaChange=true / onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff +Action matrix (situation → action [source]): + EMPTY_DIFF SKIPPED [pre-v2 hardcoded default] + ALL_FILES_IGNORED SKIPPED [pre-v2 hardcoded default] + ALL_FILES_OUT_OF_SCOPE SKIPPED [pre-v2 hardcoded default] + UNMAPPED_FILE FULL_SUITE [pre-v2 hardcoded default] + DISCOVERY_EMPTY SKIPPED [pre-v2 hardcoded default] + DISCOVERY_SUCCESS SELECTED [explicit onXxx setting] +=== end --explain === +``` + That's it. With zero config, the plugin will: - Diff against `origin/master` (including uncommitted + staged changes). diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java b/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java index ab8fc72..4490c63 100644 --- a/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java +++ b/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java @@ -93,6 +93,40 @@ public enum EscalationReason { RUN_ALL_ON_ALL_FILES_OUT_OF_SCOPE } + /** + * Per-bucket breakdown of the diff as classified by + * {@link PathToClassMapper}. Populated on every + * {@link AffectedTestsResult} (empty buckets when the engine + * short-circuited before mapping, e.g. on {@link Situation#EMPTY_DIFF}). + * Carried on the result so {@code --explain} and downstream log + * lines can describe "why" without re-running the mapper. + * + * @param ignoredFiles files matching {@link AffectedTestsConfig#ignorePaths()} + * @param outOfScopeFiles files under {@link AffectedTestsConfig#outOfScopeTestDirs()} + * or {@link AffectedTestsConfig#outOfScopeSourceDirs()} + * @param productionFiles {@code .java} files under a configured source dir + * @param testFiles {@code .java} files under a configured test dir + * @param unmappedFiles everything else (yaml, gradle, migrations, stray .java) + */ + public record Buckets( + Set ignoredFiles, + Set outOfScopeFiles, + Set productionFiles, + Set testFiles, + Set unmappedFiles + ) { + public static Buckets empty() { + return new Buckets(Set.of(), Set.of(), Set.of(), Set.of(), Set.of()); + } + + /** Total file count across every bucket — always equal to the size of the diff. */ + public int total() { + return ignoredFiles.size() + outOfScopeFiles.size() + + productionFiles.size() + testFiles.size() + + unmappedFiles.size(); + } + } + /** * Result of the affected tests analysis. * @@ -108,6 +142,9 @@ public enum EscalationReason { * @param changedProductionClasses production FQNs detected in the diff * @param changedTestClasses test FQNs detected directly in the diff * (may include FQNs whose files were deleted) + * @param buckets per-bucket diff breakdown from the mapper; + * always present (empty buckets on an + * empty-diff short-circuit) * @param runAll whether the caller should run the full suite * @param skipped whether the caller should run no tests at all * (v2 — previously impossible to express) @@ -125,6 +162,7 @@ public record AffectedTestsResult( Set changedFiles, Set changedProductionClasses, Set changedTestClasses, + Buckets buckets, boolean runAll, boolean skipped, Situation situation, @@ -151,12 +189,20 @@ public AffectedTestsResult run() { if (changedFiles.isEmpty()) { log.info("No changed files detected."); return resolveAmbiguous(Situation.EMPTY_DIFF, changedFiles, - Set.of(), Set.of()); + Set.of(), Set.of(), Buckets.empty()); } PathToClassMapper mapper = new PathToClassMapper(config); MappingResult mapping = mapper.mapChangedFiles(changedFiles); + Buckets buckets = new Buckets( + Set.copyOf(mapping.ignoredFiles()), + Set.copyOf(mapping.outOfScopeFiles()), + Set.copyOf(mapping.changedProductionFiles()), + Set.copyOf(mapping.changedTestFiles()), + Set.copyOf(mapping.unmappedChangedFiles()) + ); + int diffSize = changedFiles.size(); int ignored = mapping.ignoredFiles().size(); int outOfScope = mapping.outOfScopeFiles().size(); @@ -169,12 +215,12 @@ public AffectedTestsResult run() { if (ignored == diffSize) { log.info("All {} changed file(s) matched ignorePaths.", diffSize); return resolveAmbiguous(Situation.ALL_FILES_IGNORED, changedFiles, - mapping.productionClasses(), mapping.testClasses()); + mapping.productionClasses(), mapping.testClasses(), buckets); } if (outOfScope == diffSize) { log.info("All {} changed file(s) fell under out-of-scope dirs.", diffSize); return resolveAmbiguous(Situation.ALL_FILES_OUT_OF_SCOPE, changedFiles, - mapping.productionClasses(), mapping.testClasses()); + mapping.productionClasses(), mapping.testClasses(), buckets); } if (!mapping.unmappedChangedFiles().isEmpty()) { Action action = config.actionFor(Situation.UNMAPPED_FILE); @@ -189,7 +235,7 @@ public AffectedTestsResult run() { // second fallthrough enum value. if (action != Action.SELECTED) { return emptyResult(Situation.UNMAPPED_FILE, action, changedFiles, - mapping.productionClasses(), mapping.testClasses()); + mapping.productionClasses(), mapping.testClasses(), buckets); } } @@ -240,7 +286,7 @@ public AffectedTestsResult run() { Action action = config.actionFor(Situation.DISCOVERY_EMPTY); log.info("Discovery produced no affected tests. Action: {}.", action); return emptyResult(Situation.DISCOVERY_EMPTY, action, changedFiles, - mapping.productionClasses(), mapping.testClasses()); + mapping.productionClasses(), mapping.testClasses(), buckets); } log.info("=== Result: {} affected test classes ===", allTestsToRun.size()); @@ -252,6 +298,7 @@ public AffectedTestsResult run() { changedFiles, mapping.productionClasses(), mapping.testClasses(), + buckets, false, false, Situation.DISCOVERY_SUCCESS, @@ -275,7 +322,8 @@ public AffectedTestsResult run() { private AffectedTestsResult resolveAmbiguous(Situation situation, Set changedFiles, Set changedProduction, - Set changedTests) { + Set changedTests, + Buckets buckets) { Action action = config.actionFor(situation); if (action == Action.SELECTED) { // The only meaningful way for SELECTED to reach here is @@ -285,13 +333,14 @@ private AffectedTestsResult resolveAmbiguous(Situation situation, // "do nothing, don't claim a full run". log.info("Situation {} → SELECTED with empty selection; running no tests.", situation); } - return emptyResult(situation, action, changedFiles, changedProduction, changedTests); + return emptyResult(situation, action, changedFiles, changedProduction, changedTests, buckets); } private AffectedTestsResult emptyResult(Situation situation, Action action, Set changedFiles, Set changedProduction, - Set changedTests) { + Set changedTests, + Buckets buckets) { boolean runAll = action == Action.FULL_SUITE; // SELECTED on an ambiguous branch is treated as "skipped" for the // Gradle task's wiring — there is nothing to dispatch either way — @@ -305,6 +354,7 @@ private AffectedTestsResult emptyResult(Situation situation, Action action, changedFiles, changedProduction, changedTests, + buckets, runAll, skipped, situation, diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/ActionSource.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/ActionSource.java new file mode 100644 index 0000000..232869c --- /dev/null +++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/ActionSource.java @@ -0,0 +1,42 @@ +package io.affectedtests.core.config; + +/** + * Records which tier of the priority ladder picked the + * {@link Action} for a given {@link Situation}. Surfaced so + * {@code --explain} can tell an operator why a decision was made, not + * just what it was, without cracking open debug logs. + * + *

The ladder is evaluated strictly in the order below; the first tier + * that has a non-null value wins, and the source is stamped with that + * tier's entry in this enum. See + * {@link AffectedTestsConfig#actionSourceFor(Situation)}. + */ +public enum ActionSource { + /** + * The caller set an explicit {@code onXxx(Action)} on the builder. + * This is the only tier that survives every future default change — + * if the plugin's defaults shift again, this entry still wins. + */ + EXPLICIT, + + /** + * The caller set one of the legacy boolean flags + * ({@code runAllIfNoMatches}, {@code runAllOnNonJavaChange}) and the + * translation shim picked the action from it. + */ + LEGACY_BOOLEAN, + + /** + * The caller set an explicit {@link Mode} but not the more-specific + * explicit or legacy setting, and the mode's default table supplied + * the action. + */ + MODE_DEFAULT, + + /** + * No caller setting at any tier applies; the pre-v2 hardcoded + * default was used. Zero-config installs observe this for every + * situation until they set a mode. + */ + HARDCODED_DEFAULT +} diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java index a49bb06..defe22f 100644 --- a/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java +++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java @@ -63,6 +63,7 @@ public final class AffectedTestsConfig { private final Mode mode; private final Mode effectiveMode; private final Map situationActions; + private final Map situationActionSources; private AffectedTestsConfig(Builder builder) { this.baseRef = builder.baseRef; @@ -110,7 +111,15 @@ private AffectedTestsConfig(Builder builder) { this.runAllOnNonJavaChange = builder.runAllOnNonJavaChange != null ? builder.runAllOnNonJavaChange : true; - this.situationActions = resolveSituationActions(builder, this.effectiveMode); + ResolvedActions resolved = resolveSituationActions(builder, this.effectiveMode); + this.situationActions = resolved.actions; + this.situationActionSources = resolved.sources; + } + + /** Parallel pair returned from the situation-action resolver. */ + private record ResolvedActions( + Map actions, + Map sources) { } /** @@ -141,7 +150,7 @@ private static Mode resolveEffectiveMode(Mode configured) { * pre-v2 behaviour exactly. * */ - private static Map resolveSituationActions(Builder b, Mode effectiveMode) { + private static ResolvedActions resolveSituationActions(Builder b, Mode effectiveMode) { Action legacyNoMatches = (b.runAllIfNoMatches == null) ? null : (b.runAllIfNoMatches ? Action.FULL_SUITE : Action.SKIPPED); @@ -154,37 +163,57 @@ private static Map resolveSituationActions(Builder b, Mode ef ? null : (b.runAllOnNonJavaChange ? Action.FULL_SUITE : Action.SELECTED); - EnumMap m = new EnumMap<>(Situation.class); - m.put(Situation.EMPTY_DIFF, - resolveSituation(Situation.EMPTY_DIFF, b.onEmptyDiff, legacyNoMatches, effectiveMode, Action.SKIPPED)); - m.put(Situation.ALL_FILES_IGNORED, - resolveSituation(Situation.ALL_FILES_IGNORED, b.onAllFilesIgnored, legacyNoMatches, effectiveMode, Action.SKIPPED)); - m.put(Situation.ALL_FILES_OUT_OF_SCOPE, - // No legacy boolean maps to this situation — the "out of - // scope" concept did not exist pre-v2, so there is nothing - // to translate and the hard-coded fallback is {@code SKIPPED}. - resolveSituation(Situation.ALL_FILES_OUT_OF_SCOPE, b.onAllFilesOutOfScope, null, effectiveMode, Action.SKIPPED)); - m.put(Situation.UNMAPPED_FILE, - resolveSituation(Situation.UNMAPPED_FILE, b.onUnmappedFile, legacyNonJava, effectiveMode, Action.FULL_SUITE)); - m.put(Situation.DISCOVERY_EMPTY, - resolveSituation(Situation.DISCOVERY_EMPTY, b.onDiscoveryEmpty, legacyNoMatches, effectiveMode, Action.SKIPPED)); - // DISCOVERY_SUCCESS is definitionally SELECTED — there is no other - // sensible outcome when discovery returns tests. Making it a - // configurable action would let users set "discovery ran, found - // tests, now run nothing" which is never what anyone wants. - m.put(Situation.DISCOVERY_SUCCESS, Action.SELECTED); - return Map.copyOf(m); + EnumMap actions = new EnumMap<>(Situation.class); + EnumMap sources = new EnumMap<>(Situation.class); + resolveInto(actions, sources, Situation.EMPTY_DIFF, + b.onEmptyDiff, legacyNoMatches, effectiveMode, Action.SKIPPED); + resolveInto(actions, sources, Situation.ALL_FILES_IGNORED, + b.onAllFilesIgnored, legacyNoMatches, effectiveMode, Action.SKIPPED); + // No legacy boolean maps to ALL_FILES_OUT_OF_SCOPE — the concept + // did not exist pre-v2, so there is nothing to translate and the + // hard-coded fallback is {@code SKIPPED}. + resolveInto(actions, sources, Situation.ALL_FILES_OUT_OF_SCOPE, + b.onAllFilesOutOfScope, null, effectiveMode, Action.SKIPPED); + resolveInto(actions, sources, Situation.UNMAPPED_FILE, + b.onUnmappedFile, legacyNonJava, effectiveMode, Action.FULL_SUITE); + resolveInto(actions, sources, Situation.DISCOVERY_EMPTY, + b.onDiscoveryEmpty, legacyNoMatches, effectiveMode, Action.SKIPPED); + // DISCOVERY_SUCCESS is definitionally SELECTED — there is no + // other sensible outcome when discovery returns tests. Making + // it configurable would let users set "discovery ran, found + // tests, now run nothing", which is never what anyone wants. + // It is reported as EXPLICIT in the source map because that's + // the honest answer — the code fixes it rather than choosing a + // default that someone could override. + actions.put(Situation.DISCOVERY_SUCCESS, Action.SELECTED); + sources.put(Situation.DISCOVERY_SUCCESS, ActionSource.EXPLICIT); + return new ResolvedActions(Map.copyOf(actions), Map.copyOf(sources)); } - private static Action resolveSituation(Situation s, - Action explicit, - Action legacy, - Mode effectiveMode, - Action preV2Default) { - if (explicit != null) return explicit; - if (legacy != null) return legacy; - if (effectiveMode != null) return defaultFor(s, effectiveMode); - return preV2Default; + private static void resolveInto(EnumMap actions, + EnumMap sources, + Situation s, + Action explicit, + Action legacy, + Mode effectiveMode, + Action preV2Default) { + if (explicit != null) { + actions.put(s, explicit); + sources.put(s, ActionSource.EXPLICIT); + return; + } + if (legacy != null) { + actions.put(s, legacy); + sources.put(s, ActionSource.LEGACY_BOOLEAN); + return; + } + if (effectiveMode != null) { + actions.put(s, defaultFor(s, effectiveMode)); + sources.put(s, ActionSource.MODE_DEFAULT); + return; + } + actions.put(s, preV2Default); + sources.put(s, ActionSource.HARDCODED_DEFAULT); } /** @@ -320,6 +349,29 @@ public Action actionFor(Situation situation) { */ public Map situationActions() { return situationActions; } + /** + * The {@link ActionSource} that picked the {@link Action} for a given + * {@link Situation}. Used by {@code --explain} so operators can tell + * whether an outcome came from an explicit setting, a legacy boolean, + * a mode default, or the pre-v2 hardcoded baseline. + * + * @param situation the situation to resolve + * @return the source tier that produced {@link #actionFor(Situation)} + */ + public ActionSource actionSourceFor(Situation situation) { + return Objects.requireNonNull(situationActionSources.get(situation), + "no action source for " + situation); + } + + /** + * View of the per-situation {@link ActionSource} map. Kept immutable + * and aligned with {@link #situationActions()} so consumers can zip + * the two for diagnostic output. + * + * @return an immutable situation-to-source map + */ + public Map situationActionSources() { return situationActionSources; } + /** Creates a builder with sensible defaults. */ public static Builder builder() { return new Builder(); diff --git a/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java b/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java index e0f1b25..271b959 100644 --- a/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java +++ b/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java @@ -156,6 +156,53 @@ void runAllOnNonJavaChangeFalseTranslatesToSelected() { assertEquals(Action.SELECTED, config.actionFor(Situation.UNMAPPED_FILE)); } + @Test + void actionSourceReflectsResolutionTierOrdering() { + // Zero-config → hardcoded pre-v2 default. This is the baseline + // every other tier overrides; pinning it here prevents a future + // refactor from silently bumping zero-config users to a mode + // default when they never set one. + AffectedTestsConfig baseline = AffectedTestsConfig.builder().build(); + assertEquals(ActionSource.HARDCODED_DEFAULT, + baseline.actionSourceFor(Situation.EMPTY_DIFF)); + assertEquals(ActionSource.HARDCODED_DEFAULT, + baseline.actionSourceFor(Situation.UNMAPPED_FILE)); + + // Setting mode should flip all unpinned situations to MODE_DEFAULT — + // the only escape hatch is an explicit onXxx or a legacy boolean. + AffectedTestsConfig modeOnly = AffectedTestsConfig.builder() + .mode(Mode.CI) + .build(); + assertEquals(ActionSource.MODE_DEFAULT, + modeOnly.actionSourceFor(Situation.DISCOVERY_EMPTY)); + + // Legacy boolean must win over mode, or the shim would lie about + // what the caller asked for. + AffectedTestsConfig legacyOverMode = AffectedTestsConfig.builder() + .mode(Mode.STRICT) + .runAllIfNoMatches(false) + .build(); + assertEquals(ActionSource.LEGACY_BOOLEAN, + legacyOverMode.actionSourceFor(Situation.DISCOVERY_EMPTY)); + + // Explicit onXxx must win over both legacy and mode — this is the + // only tier that's guaranteed to survive future default changes + // and the --explain flag has to be honest about that. + AffectedTestsConfig explicitOverEverything = AffectedTestsConfig.builder() + .mode(Mode.STRICT) + .runAllIfNoMatches(false) + .onDiscoveryEmpty(Action.FULL_SUITE) + .build(); + assertEquals(ActionSource.EXPLICIT, + explicitOverEverything.actionSourceFor(Situation.DISCOVERY_EMPTY)); + + // DISCOVERY_SUCCESS is hardcoded SELECTED regardless of + // configuration — that contract is reported as EXPLICIT because + // no default can change it. + assertEquals(ActionSource.EXPLICIT, + baseline.actionSourceFor(Situation.DISCOVERY_SUCCESS)); + } + @Test void ignorePathsAliasesExcludePaths() { AffectedTestsConfig byExclude = AffectedTestsConfig.builder() diff --git a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java index 7a13e17..24ffec9 100644 --- a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java +++ b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java @@ -2,8 +2,10 @@ import io.affectedtests.core.AffectedTestsEngine; import io.affectedtests.core.AffectedTestsEngine.AffectedTestsResult; +import io.affectedtests.core.AffectedTestsEngine.Buckets; import io.affectedtests.core.AffectedTestsEngine.EscalationReason; import io.affectedtests.core.config.Action; +import io.affectedtests.core.config.ActionSource; import io.affectedtests.core.config.AffectedTestsConfig; import io.affectedtests.core.config.Mode; import io.affectedtests.core.config.Situation; @@ -16,6 +18,7 @@ import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.options.Option; import org.gradle.process.ExecOperations; import org.gradle.process.ExecResult; @@ -26,9 +29,11 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; /** * Gradle task that detects affected tests and executes them. @@ -262,6 +267,25 @@ public AffectedTestTask() { @org.gradle.api.tasks.Optional public abstract Property getOnDiscoveryEmpty(); + /** + * When {@code true}, the task prints the full decision trace + * (buckets, situation, action, action source) and exits without + * running any tests. Used by operators to answer "why did this MR + * land on that outcome?" without having to enable debug logs. + * + *

Marked {@link Internal @Internal} rather than {@link Input @Input} + * because flipping the explain flag must not invalidate a cached + * execution — it changes only the lifecycle logging, never the set + * of tests Gradle would actually run. + * + * @return the explain flag property + */ + @Internal + @Option(option = "explain", + description = "Print the decision trace (buckets, situation, action, source) " + + "and exit without running tests.") + public abstract Property getExplain(); + /** * Map of subproject directory (relative to the root project, empty string * for the root project itself) to the Gradle path of that subproject @@ -301,6 +325,19 @@ public void runAffectedTests() { AffectedTestsEngine engine = new AffectedTestsEngine(config, projectDir); AffectedTestsResult result = engine.run(); + boolean explain = getExplain().getOrElse(false); + if (explain) { + // {@code --explain} is a diagnostic mode: we print the trace and + // return without touching the executor so operators can re-run + // as many times as they need without waiting for the suite. + // Every line goes through {@code lifecycle()} so the trace is + // visible by default (no {@code --info} gymnastics). + for (String line : renderExplainTrace(config, result)) { + getLogger().lifecycle(line); + } + return; + } + // The summary line is the single place the task names the trigger; // downstream lines must not repeat the reason or CI logs drift into // contradictory duplicate phrasing. We hand the logger a format @@ -623,6 +660,124 @@ static String describeEscalation(EscalationReason reason) { * {@code class(es)}, and {@code class(es)} on both branches so CI * greps stay stable across runs with different selection sizes. */ + /** Cap on files listed per bucket in the {@code --explain} trace. */ + static final int EXPLAIN_SAMPLE_LIMIT = 10; + + /** + * Renders the human-readable decision trace produced by + * {@code affectedTest --explain}. Returned as a list of lines so the + * caller can hand each line to {@link org.gradle.api.logging.Logger#lifecycle(String)} + * (no format placeholders — the content is pre-rendered) and so tests + * can pin the exact shape without the live logger. + * + *

Every section names the source of the decision so an operator + * can see at a glance whether the action came from an explicit + * {@code onXxx} setting, a legacy boolean, the mode default table, + * or the pre-v2 hardcoded baseline. + * + *

Package-private so {@code AffectedTestTaskExplainFormatTest} + * can assert the format without spinning up Gradle. + */ + static List renderExplainTrace(AffectedTestsConfig config, AffectedTestsResult result) { + List lines = new ArrayList<>(); + lines.add("=== Affected Tests — decision trace (--explain) ==="); + lines.add("Base ref: " + config.baseRef()); + String configuredMode = config.mode() == null ? "unset" : config.mode().name(); + String effectiveMode = config.effectiveMode() == null + ? "n/a (pre-v2 defaults)" + : config.effectiveMode().name(); + lines.add("Mode: " + configuredMode + " (effective: " + effectiveMode + ")"); + lines.add("Changed files: " + result.changedFiles().size()); + + Buckets buckets = result.buckets(); + lines.add("Buckets:"); + lines.add(" ignored " + buckets.ignoredFiles().size()); + lines.add(" out-of-scope " + buckets.outOfScopeFiles().size()); + lines.add(" production .java " + buckets.productionFiles().size()); + lines.add(" test .java " + buckets.testFiles().size()); + lines.add(" unmapped " + buckets.unmappedFiles().size()); + + appendSample(lines, "ignored", buckets.ignoredFiles()); + appendSample(lines, "out-of-scope", buckets.outOfScopeFiles()); + appendSample(lines, "production", buckets.productionFiles()); + appendSample(lines, "test", buckets.testFiles()); + appendSample(lines, "unmapped", buckets.unmappedFiles()); + + ActionSource source = config.actionSourceFor(result.situation()); + lines.add("Situation: " + result.situation().name()); + lines.add("Action: " + result.action().name() + + " (source: " + describeSource(source) + ")"); + + String outcome; + if (result.runAll()) { + outcome = "FULL_SUITE — " + describeEscalation(result.escalationReason()); + } else if (result.skipped()) { + outcome = "SKIPPED — no tests will run"; + } else if (result.action() == Action.SELECTED) { + outcome = "SELECTED — " + result.testClassFqns().size() + + " test class(es) will run"; + } else { + outcome = result.action().name(); + } + lines.add("Outcome: " + outcome); + + // The full action matrix is cheap to print (five rows) and + // invaluable for debugging "why did my explicit setting not + // win?" — so we always include it, not only on ambiguous + // branches. Rows are rendered in a stable order matching the + // Situation javadoc's evaluation order so greps/diffs stay + // stable across runs. + lines.add("Action matrix (situation → action [source]):"); + for (Situation s : situationOrder()) { + ActionSource rowSource = config.actionSourceFor(s); + lines.add(String.format(Locale.ROOT, " %-24s %s [%s]", + s.name(), config.actionFor(s).name(), describeSource(rowSource))); + } + lines.add("=== end --explain ==="); + return lines; + } + + private static void appendSample(List lines, String label, Set files) { + if (files.isEmpty()) { + return; + } + String preview = files.stream() + .sorted() + .limit(EXPLAIN_SAMPLE_LIMIT) + .collect(Collectors.joining(", ")); + if (files.size() > EXPLAIN_SAMPLE_LIMIT) { + preview = preview + ", … (+" + (files.size() - EXPLAIN_SAMPLE_LIMIT) + " more)"; + } + lines.add(" " + label + " sample: " + preview); + } + + /** + * Situation order used by the explain trace and anywhere else we + * render a full per-situation matrix. Matches the evaluation order + * documented on {@link Situation} so operators can read the trace + * top-to-bottom and mentally simulate the engine without cross- + * referencing another doc. + */ + private static List situationOrder() { + return List.of( + Situation.EMPTY_DIFF, + Situation.ALL_FILES_IGNORED, + Situation.ALL_FILES_OUT_OF_SCOPE, + Situation.UNMAPPED_FILE, + Situation.DISCOVERY_EMPTY, + Situation.DISCOVERY_SUCCESS + ); + } + + private static String describeSource(ActionSource source) { + return switch (source) { + case EXPLICIT -> "explicit onXxx setting"; + case LEGACY_BOOLEAN -> "legacy boolean (runAllIfNoMatches / runAllOnNonJavaChange)"; + case MODE_DEFAULT -> "mode default"; + case HARDCODED_DEFAULT -> "pre-v2 hardcoded default"; + }; + } + static LogLine renderSummary(AffectedTestsResult result) { if (result.runAll()) { return new LogLine( diff --git a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskExplainFormatTest.java b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskExplainFormatTest.java new file mode 100644 index 0000000..ef53e10 --- /dev/null +++ b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskExplainFormatTest.java @@ -0,0 +1,296 @@ +package io.affectedtests.gradle; + +import io.affectedtests.core.AffectedTestsEngine.AffectedTestsResult; +import io.affectedtests.core.AffectedTestsEngine.Buckets; +import io.affectedtests.core.AffectedTestsEngine.EscalationReason; +import io.affectedtests.core.config.Action; +import io.affectedtests.core.config.AffectedTestsConfig; +import io.affectedtests.core.config.Mode; +import io.affectedtests.core.config.Situation; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the shape of the {@code affectedTest --explain} trace so the + * operator experience — the only visible contract for this flag — cannot + * regress unnoticed. The renderer is a pure function over + * {@link AffectedTestsConfig} and {@link AffectedTestsResult}, so the + * tests call it directly instead of spinning up a Gradle test runtime. + * + *

Every assertion targets a separate guarantee: the trace carries the + * bucket breakdown, names the situation, names the action, exposes which + * tier of the priority ladder picked that action, and always emits the + * full per-situation matrix so operators can see setting interactions at + * a glance. + */ +class AffectedTestTaskExplainFormatTest { + + private static String joined(List lines) { + return String.join("\n", lines); + } + + @Test + void traceIncludesHeaderAndFooterFromAnySituation() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of(), Set.of(), Set.of(), + Buckets.empty(), + false, true, + Situation.EMPTY_DIFF, + Action.SKIPPED, + EscalationReason.NONE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.startsWith("=== Affected Tests — decision trace (--explain) ==="), + "Header must be the first line so operators (and log greps) can pin trace start"); + assertTrue(trace.endsWith("=== end --explain ==="), + "Footer must close the trace so multi-run CI logs stay parseable"); + } + + @Test + void bucketBreakdownReportsAllFiveCountsWithSamples() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + Buckets buckets = new Buckets( + Set.of("README.md"), + Set.of("api-test/src/test/java/FooSteps.java"), + Set.of("src/main/java/com/example/Foo.java"), + Set.of("src/test/java/com/example/FooTest.java"), + Set.of("build.gradle", "application.yml")); + AffectedTestsResult result = new AffectedTestsResult( + Set.of("com.example.FooTest"), Map.of(), + Set.of("README.md", "build.gradle", "application.yml", + "src/main/java/com/example/Foo.java", + "src/test/java/com/example/FooTest.java", + "api-test/src/test/java/FooSteps.java"), + Set.of("com.example.Foo"), Set.of("com.example.FooTest"), + buckets, + false, false, + Situation.DISCOVERY_SUCCESS, Action.SELECTED, + EscalationReason.NONE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("ignored 1"), + "Ignored bucket count must surface — otherwise operators can't tell that a " + + "zero-config README-only MR didn't just 'do nothing for no reason'"); + assertTrue(trace.contains("out-of-scope 1"), + "Out-of-scope count must surface separately from ignored — they come from " + + "different configuration surfaces and mixing them would mask bugs"); + assertTrue(trace.contains("production .java 1")); + assertTrue(trace.contains("test .java 1")); + assertTrue(trace.contains("unmapped 2"), + "Unmapped count must always surface so Yaml/Gradle/Liquibase diffs don't hide"); + assertTrue(trace.contains("ignored sample: README.md"), + "A non-empty bucket must list its sample so the operator sees WHICH file caused it"); + } + + @Test + void bucketSampleIsTruncatedWithRemainderCount() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + // 15 files > EXPLAIN_SAMPLE_LIMIT (10): the trace must say "+5 more" + // rather than dumping 15 paths into a single line and burying the + // signal in CI scrollback. + java.util.Set many = new java.util.LinkedHashSet<>(); + for (int i = 0; i < 15; i++) { + many.add("build-scripts/file-" + i + ".gradle"); + } + Buckets buckets = new Buckets(Set.of(), Set.of(), Set.of(), Set.of(), many); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + many, Set.of(), Set.of(), + buckets, + true, false, + Situation.UNMAPPED_FILE, Action.FULL_SUITE, + EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("(+5 more)"), + "Truncation suffix must name the exact remainder so a 15-file diff can't " + + "masquerade as a 10-file diff in the trace"); + } + + @Test + void actionSourceSurfacesLegacyBooleanWhenOnlyLegacyFlagIsSet() { + AffectedTestsConfig config = AffectedTestsConfig.builder() + .runAllIfNoMatches(false) + .build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of("README.md"), Set.of(), Set.of(), + new Buckets(Set.of("README.md"), Set.of(), Set.of(), Set.of(), Set.of()), + false, true, + Situation.ALL_FILES_IGNORED, + Action.SKIPPED, + EscalationReason.NONE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("Action: SKIPPED (source: legacy boolean"), + "Setting only the legacy boolean must surface as LEGACY_BOOLEAN in the trace, " + + "not silently show up as a mode default or explicit setting"); + } + + @Test + void actionSourceSurfacesExplicitSettingWhenOnXxxWins() { + AffectedTestsConfig config = AffectedTestsConfig.builder() + .onAllFilesIgnored(Action.FULL_SUITE) + .build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of("README.md"), Set.of(), Set.of(), + new Buckets(Set.of("README.md"), Set.of(), Set.of(), Set.of(), Set.of()), + true, false, + Situation.ALL_FILES_IGNORED, + Action.FULL_SUITE, + EscalationReason.RUN_ALL_ON_ALL_FILES_IGNORED); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("source: explicit onXxx setting"), + "Explicit setting must surface distinctly from legacy/mode/hardcoded sources — " + + "otherwise the operator can't tell what survives a future default change"); + } + + @Test + void actionSourceSurfacesModeDefaultWhenOnlyModeIsSet() { + AffectedTestsConfig config = AffectedTestsConfig.builder() + .mode(Mode.CI) + .build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of("src/main/java/com/example/Orphan.java"), + Set.of("com.example.Orphan"), Set.of(), + Buckets.empty(), + true, false, + Situation.DISCOVERY_EMPTY, + Action.FULL_SUITE, + EscalationReason.RUN_ALL_IF_NO_MATCHES); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("source: mode default"), + "When only mode is set the trace must name mode as the source, not guess " + + "'explicit' — otherwise audits of CI flakiness lose the real lead"); + assertTrue(trace.contains("Mode: CI (effective: CI)"), + "Mode line must always render both configured + effective so AUTO resolution " + + "stays visible in CI logs"); + } + + @Test + void actionSourceSurfacesHardcodedDefaultForZeroConfig() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of(), Set.of(), Set.of(), + Buckets.empty(), + false, true, + Situation.EMPTY_DIFF, + Action.SKIPPED, + EscalationReason.NONE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + assertTrue(trace.contains("source: pre-v2 hardcoded default"), + "Zero-config installs must see the pre-v2 label — so operators know upgrading " + + "the plugin's defaults could silently change their behaviour if they " + + "do not pin a mode or onXxx setting"); + } + + @Test + void matrixIncludesEveryNonSuccessSituationInStableOrder() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + AffectedTestsResult result = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of(), Set.of(), Set.of(), + Buckets.empty(), + false, true, + Situation.EMPTY_DIFF, + Action.SKIPPED, + EscalationReason.NONE); + + String trace = joined(AffectedTestTask.renderExplainTrace(config, result)); + + // Every Situation must appear in the matrix so operators never have + // to ask "what would've happened if the diff had been different?" — + // the trace already answers it. + for (Situation s : Situation.values()) { + assertTrue(trace.contains(s.name()), + "Matrix must mention every Situation (missing: " + s + ")"); + } + + // Evaluation order is the contract. Each situation must appear + // before the next one per the Situation javadoc. + List expectedOrder = List.of( + Situation.EMPTY_DIFF, + Situation.ALL_FILES_IGNORED, + Situation.ALL_FILES_OUT_OF_SCOPE, + Situation.UNMAPPED_FILE, + Situation.DISCOVERY_EMPTY, + Situation.DISCOVERY_SUCCESS); + int previous = -1; + for (Situation s : expectedOrder) { + // Use the matrix row prefix (two leading spaces) to avoid + // matching the "Situation: ..." header line. + int idx = trace.indexOf("\n " + s.name()); + assertTrue(idx > previous, + "Matrix must list " + s + " after every earlier Situation so the trace " + + "mirrors the engine's evaluation order"); + previous = idx; + } + } + + @Test + void outcomeLineDistinguishesFullSuiteSkippedAndSelected() { + AffectedTestsConfig config = AffectedTestsConfig.builder().build(); + + AffectedTestsResult runAll = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of("build.gradle"), Set.of(), Set.of(), + new Buckets(Set.of(), Set.of(), Set.of(), Set.of(), Set.of("build.gradle")), + true, false, + Situation.UNMAPPED_FILE, Action.FULL_SUITE, + EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE); + AffectedTestsResult skipped = new AffectedTestsResult( + Set.of(), Map.of(), + Set.of("README.md"), Set.of(), Set.of(), + new Buckets(Set.of("README.md"), Set.of(), Set.of(), Set.of(), Set.of()), + false, true, + Situation.ALL_FILES_IGNORED, Action.SKIPPED, + EscalationReason.NONE); + AffectedTestsResult selected = new AffectedTestsResult( + Set.of("com.example.FooTest"), Map.of(), + Set.of("src/main/java/com/example/Foo.java"), + Set.of("com.example.Foo"), Set.of(), + Buckets.empty(), + false, false, + Situation.DISCOVERY_SUCCESS, Action.SELECTED, + EscalationReason.NONE); + + String runAllTrace = joined(AffectedTestTask.renderExplainTrace(config, runAll)); + String skippedTrace = joined(AffectedTestTask.renderExplainTrace(config, skipped)); + String selectedTrace = joined(AffectedTestTask.renderExplainTrace(config, selected)); + + assertTrue(runAllTrace.contains("Outcome: FULL_SUITE"), + "FULL_SUITE outcome must name itself so the reader never has to infer from counts"); + assertTrue(runAllTrace.contains("non-Java or unmapped"), + "FULL_SUITE outcome must carry the same phrase as the summary line so CI greps stay stable"); + assertTrue(skippedTrace.contains("Outcome: SKIPPED"), + "Skipped outcome must say so explicitly — ambiguous w/ 'SELECTED with empty selection' otherwise"); + assertTrue(selectedTrace.contains("1 test class(es) will run"), + "Selected outcome must name the actual dispatch size, not just 'SELECTED'"); + // Narrow match to the Outcome line specifically — the matrix below + // it legitimately prints FULL_SUITE for other situations and we do + // not want to assert on that here. + assertFalse(selectedTrace.contains("Outcome: FULL_SUITE"), + "SELECTED result must not render FULL_SUITE on the Outcome line"); + } +} diff --git a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java index 38aa487..7020dcb 100644 --- a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java +++ b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java @@ -1,6 +1,7 @@ package io.affectedtests.gradle; import io.affectedtests.core.AffectedTestsEngine.AffectedTestsResult; +import io.affectedtests.core.AffectedTestsEngine.Buckets; import io.affectedtests.core.AffectedTestsEngine.EscalationReason; import io.affectedtests.core.config.Action; import io.affectedtests.core.config.Situation; @@ -38,6 +39,7 @@ void nonEscalatedSelectionRendersProductionAndTestCounts() { Set.of("src/main/java/com/example/Foo.java"), Set.of("com.example.Foo"), Set.of(), + Buckets.empty(), false, false, Situation.DISCOVERY_SUCCESS, @@ -63,6 +65,7 @@ void nonJavaEscalationNamesTheRealTrigger() { Set.of("src/main/resources/application.yml"), Set.of(), Set.of(), + Buckets.empty(), true, false, Situation.UNMAPPED_FILE, @@ -92,6 +95,7 @@ void emptyChangesetEscalationNamesItsOwnTrigger() { Set.of(), Set.of(), Set.of(), + Buckets.empty(), true, false, Situation.EMPTY_DIFF, @@ -117,6 +121,7 @@ void postDiscoveryEmptyEscalationDistinguishesItselfFromEmptyChangeset() { Set.of("src/main/java/com/example/Orphan.java"), Set.of("com.example.Orphan"), Set.of(), + Buckets.empty(), true, false, Situation.DISCOVERY_EMPTY, @@ -138,6 +143,7 @@ void pluralisationIsConsistentAcrossSummaryLines() { Set.of(), Map.of(), Set.of("src/main/resources/application.yml"), Set.of(), Set.of(), + Buckets.empty(), true, false, Situation.UNMAPPED_FILE, Action.FULL_SUITE, EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE); @@ -147,6 +153,7 @@ void pluralisationIsConsistentAcrossSummaryLines() { Set.of("src/main/java/com/example/Foo.java"), Set.of("com.example.Foo"), Set.of(), + Buckets.empty(), false, false, Situation.DISCOVERY_SUCCESS, Action.SELECTED, EscalationReason.NONE); @@ -176,6 +183,7 @@ void formatPlaceholderCountMatchesArgsLengthOnBothBranches() { Set.of(), Map.of(), Set.of("src/main/resources/application.yml"), Set.of(), Set.of(), + Buckets.empty(), true, false, Situation.UNMAPPED_FILE, Action.FULL_SUITE, EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE); @@ -185,6 +193,7 @@ void formatPlaceholderCountMatchesArgsLengthOnBothBranches() { Set.of("src/main/java/com/example/Foo.java"), Set.of("com.example.Foo"), Set.of(), + Buckets.empty(), false, false, Situation.DISCOVERY_SUCCESS, Action.SELECTED, EscalationReason.NONE);