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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

Nothing yet.
### Added

- `outOfScopeTestDirs` and `outOfScopeSourceDirs` now accept Ant-style
globs (`api-test/**`, `**/api-test/**`, `{api,perf}-test/**`) in
addition to the existing literal directory prefixes. Each list entry
is classified independently, so users can mix both shapes in the
same config. Surfaced after a real adopter configured
`outOfScopeTestDirs = ['api-test/**']` and the engine silently
treated it as a literal prefix — which never matched.
- New `Hint:` line on `affectedTest --explain` when
`outOfScopeTestDirs` / `outOfScopeSourceDirs` are configured but
zero files in the diff landed in the out-of-scope bucket. Points at
the configured knob so the operator learns about the silent
misconfiguration on the trace instead of after a 30-minute full-suite
CI run. The hint is suppressed on empty diffs, on runs where the
bucket is non-empty, and on zero-config installs — so its rarity is
itself a signal.

## [1.9.12] — 2026-04-22

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ plugins {

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.

When `outOfScopeTestDirs` / `outOfScopeSourceDirs` are configured but zero files in the diff land in the out-of-scope bucket, the trace emits a one-line `Hint:` pointing at the configured knob. That's the silent-failure trap a real adopter hit: a perfectly valid-looking glob that never bit anything, which the plugin would otherwise only surface after a 30-minute full-suite CI run.

Sample output:

```
Expand Down Expand Up @@ -164,9 +166,24 @@ affectedTests {
// Test source sets the plugin must not dispatch via the affectedTest
// task (e.g. Cucumber, Gatling). A diff entirely under these dirs
// routes to ALL_FILES_OUT_OF_SCOPE → SKIPPED by default.
outOfScopeTestDirs = ["api-test/src/test/java", "api-test/src/test/resources"]
//
// Entries may be either:
// • literal directory prefixes — "api-test/src/test/java" matches
// that path at the repo root or under any module
// (e.g. "services/orders/api-test/src/test/java/..."), and
// "api-test" (no source-dir suffix) never claims sibling names
// like "api-test-utils/...";
// • Ant-style globs — "api-test/**" or "**/api-test/**" — using
// the standard JVM glob syntax ("*", "**", "?", "[abc]", "{a,b}").
//
// Mix both shapes freely; the plugin picks the right semantics per
// entry. If you configure this knob but see "Hint:" on --explain
// saying zero files matched, your paths/globs don't bite — double
// check them (that's the silent-failure trap sanity testing caught).
outOfScopeTestDirs = ["api-test/**", "performance-test/**"]

// Production source sets the plugin must treat as out-of-scope.
// Accepts the same literal-prefix / glob shapes as outOfScopeTestDirs.
outOfScopeSourceDirs = []

// ---------------- Per-situation actions (v2) ----------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

import java.nio.file.FileSystems;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;

/**
* Maps file paths (from git diff output) to fully-qualified Java class names.
Expand All @@ -29,12 +31,21 @@ public final class PathToClassMapper {

private final AffectedTestsConfig config;
private final List<PathMatcher> ignoreMatchers;
private final List<Predicate<String>> outOfScopeTestMatchers;
private final List<Predicate<String>> outOfScopeSourceMatchers;

public PathToClassMapper(AffectedTestsConfig config) {
this.config = config;
this.ignoreMatchers = config.ignorePaths().stream()
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
.toList();
// Out-of-scope dirs are compiled once, up-front, so every diff
// pays only the matcher cost instead of re-parsing the config
// strings per-file. The pre-compile step is also where we decide
// between glob and literal-prefix semantics, so users can mix
// both shapes in the same list without surprise.
this.outOfScopeTestMatchers = compileOutOfScopeMatchers(config.outOfScopeTestDirs());
this.outOfScopeSourceMatchers = compileOutOfScopeMatchers(config.outOfScopeSourceDirs());
}

/**
Expand Down Expand Up @@ -94,8 +105,8 @@ public MappingResult mapChangedFiles(Set<String> changedFiles) {
// not mis-filed as an in-scope test class. Source-dir check is
// first because real code is more common in diffs than test
// code under an out-of-scope test dir.
if (isUnder(filePath, config.outOfScopeSourceDirs())
|| isUnder(filePath, config.outOfScopeTestDirs())) {
if (matchesAny(filePath, outOfScopeSourceMatchers)
|| matchesAny(filePath, outOfScopeTestMatchers)) {
outOfScopeFiles.add(filePath);
log.debug("Out-of-scope: {}", filePath);
continue;
Expand Down Expand Up @@ -227,20 +238,95 @@ private boolean isIgnored(String filePath) {
}

/**
* Boundary-aware "is this file under any of the given dirs?" check.
* Uses the same normalisation as {@link #tryMapToClass} so
* {@code "api-test/src/test/java/..."} matches {@code "api-test/src/test/java"}
* but {@code "my-api-test/..."} does not.
* Evaluates the pre-compiled out-of-scope matcher list against the
* normalised file path. Returns {@code true} as soon as any matcher
* claims the path so large configs short-circuit on the first hit.
*/
private static boolean isUnder(String filePath, List<String> dirs) {
if (dirs.isEmpty()) return false;
private static boolean matchesAny(String filePath, List<Predicate<String>> matchers) {
if (matchers.isEmpty()) return false;
String normalized = filePath.replace('\\', '/');
for (Predicate<String> matcher : matchers) {
if (matcher.test(normalized)) return true;
}
return false;
}

/**
* Compiles each raw out-of-scope dir string into a {@link Predicate}
* that answers "does this (normalised) file path sit under this
* entry?".
*
* <p>Each entry is classified into one of two semantics based on
* whether it contains any glob metacharacter ({@code *}, {@code ?},
* {@code [}, {@code \{}):
*
* <ul>
* <li>Glob entries (e.g. {@code "api-test/&#42;&#42;"}) compile to
* a {@link PathMatcher} using the JVM's default file system
* {@code glob:} syntax, so {@code &#42;&#42;} crosses directory
* boundaries as users expect from Ant/Gradle conventions.</li>
* <li>Literal entries (e.g. {@code "api-test/src/test/java"})
* keep the boundary-aware prefix semantics the README has
* documented since v1: the entry matches only when it sits at
* the start of the path or is preceded by {@code '/'}, so
* {@code "api-test"} never claims
* {@code "api-test-utils/..."}.</li>
* </ul>
*
* <p>Mixing both shapes in the same list is supported — this is
* what lets existing adopters migrate at their own pace without the
* plugin ever silently losing coverage.
*
* <p>{@code null} and blank entries are dropped quietly: a
* mis-concatenated list literal on the Gradle side is not worth
* failing a build over, and the {@code --explain} hint already
* surfaces the more likely "configured but nothing matched" failure.
*/
private static List<Predicate<String>> compileOutOfScopeMatchers(List<String> dirs) {
if (dirs == null || dirs.isEmpty()) return List.of();
List<Predicate<String>> matchers = new ArrayList<>(dirs.size());
for (String dir : dirs) {
if (dir == null || dir.isBlank()) continue;
String normalizedDir = dir.replace('\\', '/');
if (!normalizedDir.endsWith("/")) normalizedDir += "/";
if (normalized.startsWith(normalizedDir)) return true;
if (normalized.contains("/" + normalizedDir)) return true;
if (hasGlobMetachar(normalizedDir)) {
PathMatcher pm = FileSystems.getDefault()
.getPathMatcher("glob:" + normalizedDir);
matchers.add(path -> {
try {
return pm.matches(java.nio.file.Path.of(path));
} catch (java.nio.file.InvalidPathException e) {
// A changed file whose path contains characters the
// platform refuses to parse (mostly a Windows-in-git
// edge case) can't match any glob — fail closed so
// the engine routes it through the unmapped bucket
// instead of pretending the glob bit.
return false;
}
});
} else {
// Literal-prefix branch preserves the pre-v2 "boundary-aware
// prefix" semantics verbatim: leading-edge or /-bounded.
String prefix = normalizedDir.endsWith("/") ? normalizedDir : normalizedDir + "/";
matchers.add(path ->
path.startsWith(prefix) || path.contains("/" + prefix));
}
}
return List.copyOf(matchers);
}

/**
* True if the string contains any character the JVM's
* {@code glob:} syntax treats as a metacharacter. Kept deliberately
* small: these four cover every pattern the README, Gradle docs,
* and user bug reports mention. Anything more exotic still routes
* through the literal-prefix branch and will fail closed (i.e.
* match nothing), which is safer than inferring glob intent from a
* stray character.
*/
private static boolean hasGlobMetachar(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '*' || c == '?' || c == '[' || c == '{') return true;
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,112 @@ void outOfScopeTestDirsFileRoutesToOutOfScopeBucket() {
"An out-of-scope file must not leak into the unmapped bucket");
}

@Test
void outOfScopeTestDirsAcceptGlobPattern() {
// Users reach for glob patterns (e.g. "api-test/**") as often as
// literal prefixes because the `ignorePaths` knob one line above
// accepts globs too. The matcher must accept both shapes and fail
// closed on patterns it can't interpret, otherwise users silently
// lose their out-of-scope safety net while the config file still
// looks intentional.
AffectedTestsConfig globConfig = AffectedTestsConfig.builder()
.outOfScopeTestDirs(java.util.List.of("api-test/**"))
.build();
PathToClassMapper globMapper = new PathToClassMapper(globConfig);

Set<String> changed = Set.of(
"api-test/src/test/java/com/example/api/FooSteps.java",
"api-test/src/test/resources/feature.feature");
MappingResult result = globMapper.mapChangedFiles(changed);

assertEquals(changed, result.outOfScopeFiles(),
"'api-test/**' glob must route every api-test file to the out-of-scope bucket");
assertTrue(result.testClasses().isEmpty());
assertTrue(result.unmappedChangedFiles().isEmpty());
}

@Test
void outOfScopeTestDirsGlobMatchesNestedModules() {
// Real multi-module repos put api-test under a services/ parent,
// so the matcher must support a `**/` prefix that crosses any
// number of directories — exactly the shape users copy from
// Ant/Gradle docs without thinking. A literal-prefix-only
// implementation would quietly miss the nested case.
AffectedTestsConfig globConfig = AffectedTestsConfig.builder()
.outOfScopeTestDirs(java.util.List.of("**/api-test/**"))
.build();
PathToClassMapper globMapper = new PathToClassMapper(globConfig);

Set<String> changed = Set.of(
"services/orders/api-test/src/test/java/com/example/OrderSteps.java");
MappingResult result = globMapper.mapChangedFiles(changed);

assertEquals(changed, result.outOfScopeFiles(),
"Nested api-test dir must match the '**/api-test/**' glob");
}

@Test
void outOfScopeSourceDirsAcceptGlobPattern() {
// Symmetry: whatever shape outOfScopeTestDirs accepts,
// outOfScopeSourceDirs must accept too. Users configure both at
// the same time for mono-repo setups where a whole module is
// carved out of the unit/integration dispatch.
AffectedTestsConfig globConfig = AffectedTestsConfig.builder()
.outOfScopeSourceDirs(java.util.List.of("legacy-service/**"))
.build();
PathToClassMapper globMapper = new PathToClassMapper(globConfig);

Set<String> changed = Set.of(
"legacy-service/src/main/java/com/example/LegacyFoo.java");
MappingResult result = globMapper.mapChangedFiles(changed);

assertEquals(changed, result.outOfScopeFiles(),
"'legacy-service/**' glob on outOfScopeSourceDirs must route under "
+ "legacy-service/ to the out-of-scope bucket");
assertTrue(result.productionClasses().isEmpty(),
"A file bucketed as out-of-scope must not also be mapped as production");
}

@Test
void outOfScopeLiteralPrefixStillWorksAfterGlobSupportAdded() {
// Regression guard: the existing literal-prefix shape documented
// in the README ("api-test/src/test/java") must keep working.
// Losing this would silently break every adopter who migrated to
// v2 before glob support existed.
AffectedTestsConfig prefixConfig = AffectedTestsConfig.builder()
.outOfScopeTestDirs(java.util.List.of(
"api-test/src/test/java",
"api-test/src/test/resources"))
.build();
PathToClassMapper prefixMapper = new PathToClassMapper(prefixConfig);

Set<String> changed = Set.of(
"api-test/src/test/java/com/example/FooSteps.java",
"api-test/src/test/resources/feature.feature");
MappingResult result = prefixMapper.mapChangedFiles(changed);

assertEquals(changed, result.outOfScopeFiles());
}

@Test
void outOfScopeGlobDoesNotMatchSiblingDirectory() {
// Positive-boundary: 'api-test/**' must NOT match a directory
// whose name merely starts with 'api-test' ("api-test-utils/...").
// The glob layer needs to preserve the same guarantee the literal
// prefix already had via trailing-slash normalisation.
AffectedTestsConfig globConfig = AffectedTestsConfig.builder()
.outOfScopeTestDirs(java.util.List.of("api-test/**"))
.build();
PathToClassMapper globMapper = new PathToClassMapper(globConfig);

Set<String> changed = Set.of(
"api-test-utils/src/main/java/com/example/Foo.java");
MappingResult result = globMapper.mapChangedFiles(changed);

assertTrue(result.outOfScopeFiles().isEmpty(),
"'api-test/**' must not claim 'api-test-utils/...' — name prefixes are not paths");
}

@Test
void javaFileOutsideConfiguredDirsIsUnmapped() {
// A .java file that does not sit under any configured source or test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,16 @@ static List<String> renderExplainTrace(AffectedTestsConfig config, AffectedTests
}
lines.add("Outcome: " + outcome);

// Diagnostic hint: when out-of-scope dirs are configured but the
// bucket is empty, the config is almost certainly silently
// broken (wrong path, wrong glob shape, trailing-slash typo).
// We call it out inline so the operator sees the misconfiguration
// on the same trace that shows the buckets, rather than finding
// out 30 minutes later when a full suite runs that should have
// been skipped. Suppressed on empty-diff runs — there's nothing
// for the config to have bitten.
appendOutOfScopeHint(lines, config, result);

// 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
Expand All @@ -740,6 +750,58 @@ static List<String> renderExplainTrace(AffectedTestsConfig config, AffectedTests
return lines;
}

/**
* Emits the "configured but matched nothing" hint for out-of-scope
* dirs when the signal points to a silent misconfiguration. Kept
* package-private so the explain-format tests can pin the exact
* conditions without spinning up Gradle.
*
* <p>The hint fires only when all three conditions hold:
* <ul>
* <li>at least one changed file exists (nothing to diagnose on
* an empty diff, and a re-run after every merge to master
* would otherwise spam the false alarm);</li>
* <li>at least one of {@code outOfScopeTestDirs} /
* {@code outOfScopeSourceDirs} is configured (zero-config
* installs never opted in, so the hint would just be noise);
* </li>
* <li>the out-of-scope bucket is empty (if the config IS biting,
* the bucket count already tells the story).</li>
* </ul>
*/
static void appendOutOfScopeHint(List<String> lines,
AffectedTestsConfig config,
AffectedTestsResult result) {
if (result.changedFiles().isEmpty()) {
return;
}
if (!result.buckets().outOfScopeFiles().isEmpty()) {
return;
}
int testEntries = config.outOfScopeTestDirs().size();
int sourceEntries = config.outOfScopeSourceDirs().size();
int totalEntries = testEntries + sourceEntries;
if (totalEntries == 0) {
return;
}

List<String> configuredKnobs = new ArrayList<>(2);
if (testEntries > 0) {
configuredKnobs.add("outOfScopeTestDirs");
}
if (sourceEntries > 0) {
configuredKnobs.add("outOfScopeSourceDirs");
}
String knobs = String.join(" / ", configuredKnobs);
String verb = configuredKnobs.size() == 1 ? "is" : "are";
String entryWord = totalEntries == 1 ? "entry" : "entries";

lines.add("Hint: " + knobs + " " + verb + " configured ("
+ totalEntries + " " + entryWord + ") but no file in the diff matched.");
lines.add(" Values are directory prefixes "
+ "(e.g. 'api-test/src/test/java') or globs (e.g. 'api-test/**').");
}

private static void appendSample(List<String> lines, String label, Set<String> files) {
if (files.isEmpty()) {
return;
Expand Down
Loading