Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> ignoredFiles,
Set<String> outOfScopeFiles,
Set<String> productionFiles,
Set<String> testFiles,
Set<String> 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.
*
Expand All @@ -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)
Expand All @@ -125,6 +162,7 @@ public record AffectedTestsResult(
Set<String> changedFiles,
Set<String> changedProductionClasses,
Set<String> changedTestClasses,
Buckets buckets,
boolean runAll,
boolean skipped,
Situation situation,
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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());
Expand All @@ -252,6 +298,7 @@ public AffectedTestsResult run() {
changedFiles,
mapping.productionClasses(),
mapping.testClasses(),
buckets,
false,
false,
Situation.DISCOVERY_SUCCESS,
Expand All @@ -275,7 +322,8 @@ public AffectedTestsResult run() {
private AffectedTestsResult resolveAmbiguous(Situation situation,
Set<String> changedFiles,
Set<String> changedProduction,
Set<String> changedTests) {
Set<String> changedTests,
Buckets buckets) {
Action action = config.actionFor(situation);
if (action == Action.SELECTED) {
// The only meaningful way for SELECTED to reach here is
Expand All @@ -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<String> changedFiles,
Set<String> changedProduction,
Set<String> changedTests) {
Set<String> 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 —
Expand All @@ -305,6 +354,7 @@ private AffectedTestsResult emptyResult(Situation situation, Action action,
changedFiles,
changedProduction,
changedTests,
buckets,
runAll,
skipped,
situation,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public final class AffectedTestsConfig {
private final Mode mode;
private final Mode effectiveMode;
private final Map<Situation, Action> situationActions;
private final Map<Situation, ActionSource> situationActionSources;

private AffectedTestsConfig(Builder builder) {
this.baseRef = builder.baseRef;
Expand Down Expand Up @@ -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<Situation, Action> actions,
Map<Situation, ActionSource> sources) {
}

/**
Expand Down Expand Up @@ -141,7 +150,7 @@ private static Mode resolveEffectiveMode(Mode configured) {
* pre-v2 behaviour exactly.</li>
* </ol>
*/
private static Map<Situation, Action> 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);
Expand All @@ -154,37 +163,57 @@ private static Map<Situation, Action> resolveSituationActions(Builder b, Mode ef
? null
: (b.runAllOnNonJavaChange ? Action.FULL_SUITE : Action.SELECTED);

EnumMap<Situation, Action> 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<Situation, Action> actions = new EnumMap<>(Situation.class);
EnumMap<Situation, ActionSource> 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<Situation, Action> actions,
EnumMap<Situation, ActionSource> 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);
}

/**
Expand Down Expand Up @@ -320,6 +349,29 @@ public Action actionFor(Situation situation) {
*/
public Map<Situation, Action> 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<Situation, ActionSource> situationActionSources() { return situationActionSources; }

/** Creates a builder with sensible defaults. */
public static Builder builder() {
return new Builder();
Expand Down
Loading