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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ That's it. With zero config, the plugin will:

Every invocation resolves to exactly one **Situation** and exactly one **Action**, both of which appear in the log line so an operator can tell — at a glance — why the plugin chose what it did.

### Summary log format

Every `affectedTest` run prints exactly one summary line in the form `Affected Tests: <OUTCOME> (<SITUATION>) — <details>`:

```
Affected Tests: SELECTED (DISCOVERY_SUCCESS) — 3 changed file(s), 2 production class(es), 5 test class(es) affected
Affected Tests: FULL_SUITE (UNMAPPED_FILE) — 1 changed file(s); running full suite (runAllOnNonJavaChange=true / onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff).
Affected Tests: SKIPPED (ALL_FILES_IGNORED) — 1 changed file(s); every changed file matched ignorePaths.
```

The outcome (`SELECTED` / `FULL_SUITE` / `SKIPPED`) and the situation that produced it are first-class fields on every branch, so CI dashboards can `grep -E '^Affected Tests: (SELECTED|FULL_SUITE|SKIPPED)'` and bucket runs without parsing the tail. Every pre-v2 phrase (`running full suite`, `runAllIfNoMatches=true`, `runAllOnNonJavaChange=true`, `no affected tests discovered`) still appears verbatim in the reason segment, so existing greps keep working.

### Situations (what the engine saw)

| Situation | Fires when |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,29 +338,33 @@ public void runAffectedTests() {
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
// string + args pair instead of a pre-formatted string so any future
// phrase containing `{` or `}` characters (Liquibase file names,
// The summary line is the single place the task names the
// outcome (SELECTED / FULL_SUITE / SKIPPED), the situation that
// produced it, and the reason phrase. Downstream lines must not
// repeat any of those fields or CI logs drift into contradictory
// duplicate phrasing. We hand the logger a format string + args
// pair instead of a pre-formatted string so any future phrase
// containing `{` or `}` characters (Liquibase file names,
// JSONpath expressions) renders literally rather than silently
// disappearing into the placeholder parser.
LogLine summary = renderSummary(result);
getLogger().lifecycle(summary.format(), summary.args());

// Skipped results return here without touching the executor.
// Intentionally no follow-up line — the summary already carried
// the {@code SKIPPED (SITUATION)} prefix and the reason phrase,
// so a separate "Skipping test execution (…)" log would only
// duplicate what the operator already read.
if (result.skipped()) {
// Skipped is its own first-class outcome in v2: the engine
// deliberately chose to run no tests (e.g. api-test-only diff
// with onAllFilesOutOfScope=SKIPPED). Logging it distinctly
// from "selection empty" keeps the user's --explain expectation
// honest — "skipped (situation)" vs "no affected tests".
getLogger().lifecycle("Skipping test execution ({}: {}).",
result.situation(), result.action());
return;
}

if (result.testClassFqns().isEmpty() && !result.runAll()) {
getLogger().lifecycle("No affected tests to run. Skipping test execution.");
// Defensive fallthrough: the engine should have already
// routed this into {@code SKIPPED} via
// {@link AffectedTestsEngine.Situation#DISCOVERY_EMPTY},
// but we keep the guard so a future regression surfaces as
// a log line instead of a silent no-op.
return;
}

Expand Down Expand Up @@ -649,17 +653,6 @@ static String describeEscalation(EscalationReason reason) {
};
}

/**
* Builds the single summary line printed to Gradle's lifecycle log for
* every {@code affectedTest} run. The runAll branch names the real
* trigger (so "0 production classes, 0 test classes affected" never
* sits next to "running full suite"), and non-runAll runs emit the
* selection counts the downstream test dispatch will honour.
*
* <p>Pluralisation is deliberately fixed to {@code file(s)},
* {@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;

Expand Down Expand Up @@ -778,21 +771,90 @@ private static String describeSource(ActionSource source) {
};
}

/**
* Builds the single summary line printed to Gradle's lifecycle log
* for every {@code affectedTest} run. Every branch names the
* outcome ({@link Action#SELECTED}, {@link Action#FULL_SUITE},
* {@link Action#SKIPPED}) and the {@link Situation} that produced
* it, followed by the file count and branch-specific details.
*
* <p>Shape:
* <pre>
* Affected Tests: SELECTED (DISCOVERY_SUCCESS) — N changed file(s), P production class(es), T test class(es) affected
* Affected Tests: FULL_SUITE (UNMAPPED_FILE) — N changed file(s); running full suite (reason)
* Affected Tests: SKIPPED (ALL_FILES_IGNORED) — N changed file(s); every changed file matched ignorePaths
* </pre>
*
* <p>Pluralisation is deliberately fixed to {@code file(s)} and
* {@code class(es)} across every branch so CI greps stay stable
* across runs with different selection sizes. Every phrase that
* pre-v2 or Phase-1 CI pipelines grep for ({@code "running full
* suite"}, {@code "runAllIfNoMatches=true"}, {@code "no affected
* tests discovered"}, etc.) survives verbatim — this change adds
* the outcome/situation prefix without removing any existing
* vocabulary.
*/
static LogLine renderSummary(AffectedTestsResult result) {
String prefix = "Affected Tests: " + result.action().name()
+ " (" + result.situation().name() + ") — ";
if (result.runAll()) {
// runAll branch keeps the pre-v2 "running full suite (reason)"
// wording verbatim so existing CI greps for that substring
// continue to match every FULL_SUITE run. Reason phrase is
// sourced from describeEscalation to avoid duplicating the
// legacy vocabulary across two files.
return new LogLine(
"Affected Tests: {} changed file(s); running full suite ({}).",
prefix + "{} changed file(s); running full suite ({}).",
new Object[] {
result.changedFiles().size(),
describeEscalation(result.escalationReason())
});
}
if (result.skipped()) {
return new LogLine(
prefix + "{} changed file(s); {}.",
new Object[] {
result.changedFiles().size(),
describeSkipReason(result.situation())
});
}
return new LogLine(
"Affected Tests: {} changed file(s), {} production class(es), {} test class(es) affected",
prefix + "{} changed file(s), {} production class(es), {} test class(es) affected",
new Object[] {
result.changedFiles().size(),
result.changedProductionClasses().size(),
result.testClassFqns().size()
});
}

/**
* Renders a {@link Situation} as a short human-readable phrase
* suitable for the summary line's {@code SKIPPED} branch. Only the
* five "ambiguous" situations can legitimately resolve to
* {@link Action#SKIPPED}; {@link Situation#DISCOVERY_SUCCESS} is
* rejected because the engine never skips when it found tests.
*
* <p>Package-private so {@code AffectedTestTaskLogFormatTest} can
* pin the exact wording — mirrors {@link #describeEscalation} so
* the two halves of the summary log share one vocabulary.
*/
static String describeSkipReason(Situation situation) {
Objects.requireNonNull(situation, "situation");
return switch (situation) {
case EMPTY_DIFF ->
"no changed files detected";
case ALL_FILES_IGNORED ->
"every changed file matched ignorePaths";
case ALL_FILES_OUT_OF_SCOPE ->
"every changed file sat under out-of-scope dirs";
case UNMAPPED_FILE ->
"onUnmappedFile=SKIPPED — non-Java or unmapped file in diff";
case DISCOVERY_EMPTY ->
"no affected tests discovered";
case DISCOVERY_SUCCESS -> throw new IllegalStateException(
"describeSkipReason must not be called for DISCOVERY_SUCCESS; "
+ "the engine only produces that situation on a non-empty "
+ "selection, which is never skipped");
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;
import org.slf4j.helpers.MessageFormatter;

import java.util.List;
import java.util.Map;
import java.util.Set;

Expand Down Expand Up @@ -49,12 +50,106 @@ void nonEscalatedSelectionRendersProductionAndTestCounts() {
String summary = render(AffectedTestTask.renderSummary(result));

assertEquals(
"Affected Tests: 1 changed file(s), 1 production class(es), 2 test class(es) affected",
"Affected Tests: SELECTED (DISCOVERY_SUCCESS) — 1 changed file(s), "
+ "1 production class(es), 2 test class(es) affected",
summary,
"Non-runAll summary must spell out the exact selection the downstream test "
+ "task will receive, so operators can cross-check against the module-routed "
+ "logs below — and the pluralisation form must match the runAll branch to "
+ "keep CI greps stable");
"SELECTED summary must name the outcome and the situation up front so the "
+ "operator can tell at a glance what the engine decided, then spell out "
+ "the exact selection the downstream test task will receive");
}

@Test
void summaryPrefixNamesOutcomeAndSituationOnEveryBranch() {
// The Phase 1 close-out contract: every summary line must start
// with "OUTCOME (SITUATION) —" so CI greps and human readers can
// bucket runs by outcome without having to parse the tail.
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);
AffectedTestsResult runAll = new AffectedTestsResult(
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);
AffectedTestsResult skipped = new AffectedTestsResult(
Set.of(), Map.of(),
Set.of("README.md"), Set.of(), Set.of(),
Buckets.empty(),
false, true,
Situation.ALL_FILES_IGNORED, Action.SKIPPED,
EscalationReason.NONE);

assertTrue(render(AffectedTestTask.renderSummary(selected))
.startsWith("Affected Tests: SELECTED (DISCOVERY_SUCCESS) —"),
"SELECTED prefix must name both action and situation — previously the summary "
+ "carried neither, so any operator seeing 'Affected Tests: 1 changed file(s)…' "
+ "had no idea whether discovery had actually landed or the engine was about "
+ "to full-suite");
assertTrue(render(AffectedTestTask.renderSummary(runAll))
.startsWith("Affected Tests: FULL_SUITE (UNMAPPED_FILE) —"),
"FULL_SUITE prefix must name the situation so 'UNMAPPED_FILE vs DISCOVERY_EMPTY vs "
+ "EMPTY_DIFF' is visible without cross-referencing the escalation reason");
assertTrue(render(AffectedTestTask.renderSummary(skipped))
.startsWith("Affected Tests: SKIPPED (ALL_FILES_IGNORED) —"),
"SKIPPED branch must also carry the prefix — previously skipped runs printed a "
+ "separate 'Skipping test execution (...)' line without the outcome vocabulary");
}

@Test
void skippedBranchNamesReasonPhraseForEachSituation() {
// The SKIPPED branch is new — previously the task emitted a
// separate side-line. Every non-DISCOVERY_SUCCESS situation
// must produce a distinct reason phrase so operators can tell
// "README-only MR" from "api-test-only MR" from "discovery
// found nothing" at a glance.
java.util.Map<Situation, String> expected = java.util.Map.of(
Situation.EMPTY_DIFF, "no changed files detected",
Situation.ALL_FILES_IGNORED, "every changed file matched ignorePaths",
Situation.ALL_FILES_OUT_OF_SCOPE, "every changed file sat under out-of-scope dirs",
Situation.UNMAPPED_FILE, "non-Java or unmapped file in diff",
Situation.DISCOVERY_EMPTY, "no affected tests discovered");

expected.forEach((situation, phrase) -> {
AffectedTestsResult skipped = new AffectedTestsResult(
Set.of(), Map.of(),
Set.of(), Set.of(), Set.of(),
Buckets.empty(),
false, true,
situation, Action.SKIPPED,
EscalationReason.NONE);
String summary = render(AffectedTestTask.renderSummary(skipped));
assertTrue(summary.contains(phrase),
"SKIPPED summary for " + situation + " must contain phrase '" + phrase
+ "' — operators rely on that substring to distinguish this skip "
+ "from every other skip reason");
assertTrue(summary.contains("SKIPPED (" + situation + ")"),
"SKIPPED summary must name the situation in the prefix");
});
}

@Test
void describeSkipReasonRejectsDiscoverySuccess() {
// DISCOVERY_SUCCESS + SKIPPED is an engine bug — if discovery
// returned tests we never skip. The helper must fail loudly so
// such a drift cannot surface a placeholder phrase in CI.
assertThrows(IllegalStateException.class,
() -> AffectedTestTask.describeSkipReason(Situation.DISCOVERY_SUCCESS),
"describeSkipReason(DISCOVERY_SUCCESS) must throw — that combination is an "
+ "engine contract violation, not a log-formatting concern");
}

@Test
void describeSkipReasonRejectsNull() {
assertThrows(NullPointerException.class,
() -> AffectedTestTask.describeSkipReason(null));
}

@Test
Expand Down Expand Up @@ -171,7 +266,7 @@ void pluralisationIsConsistentAcrossSummaryLines() {
}

@Test
void formatPlaceholderCountMatchesArgsLengthOnBothBranches() {
void formatPlaceholderCountMatchesArgsLengthOnEveryBranch() {
// Regression guard for the SLF4J-placeholder contract: every `{}`
// pair in the format string is consumed by exactly one positional
// arg, and every arg must have a placeholder to render into.
Expand All @@ -197,14 +292,20 @@ void formatPlaceholderCountMatchesArgsLengthOnBothBranches() {
false, false,
Situation.DISCOVERY_SUCCESS, Action.SELECTED,
EscalationReason.NONE);
AffectedTestsResult skipped = new AffectedTestsResult(
Set.of(), Map.of(),
Set.of("README.md"), Set.of(), Set.of(),
Buckets.empty(),
false, true,
Situation.ALL_FILES_IGNORED, Action.SKIPPED,
EscalationReason.NONE);

AffectedTestTask.LogLine escalatedLine = AffectedTestTask.renderSummary(escalated);
AffectedTestTask.LogLine normalLine = AffectedTestTask.renderSummary(normal);

assertEquals(countPlaceholders(escalatedLine.format()), escalatedLine.args().length,
"runAll branch must have one {} per arg so SLF4J's formatter consumes all args");
assertEquals(countPlaceholders(normalLine.format()), normalLine.args().length,
"non-runAll branch must have one {} per arg so SLF4J's formatter consumes all args");
for (AffectedTestsResult r : List.of(escalated, normal, skipped)) {
AffectedTestTask.LogLine line = AffectedTestTask.renderSummary(r);
assertEquals(countPlaceholders(line.format()), line.args().length,
"Summary branch for " + r.action() + "/" + r.situation()
+ " must have one {} per arg so SLF4J's formatter consumes all args");
}
}

private static int countPlaceholders(String format) {
Expand Down