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
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,91 @@ adheres to [Semantic Versioning](https://semver.org/).
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.
- `OutOfScopeMatchers` — an internal utility shared between
`PathToClassMapper` and `ProjectIndex`. Not a public API, but
called out here because it is the structural fix behind the
glob-alignment bug in the Fixed section below, and because its
malformed-glob error path is now the single source of truth for
`Affected Tests: invalid glob at outOfScope*Dirs[N]` messages.

### Fixed — post-v1.9.15 review batch

- `outOfScopeTestDirs` / `outOfScopeSourceDirs` glob entries now work
on both sides of the pipeline. Before this fix the diff-side
classifier in `PathToClassMapper` honoured `"api-test/**"` but the
on-disk classifier in `ProjectIndex` treated the same string as a
literal prefix, so a mixed diff (one production file + a refactor
under `api-test/`) bucketed the api-test file correctly yet still
dispatched tests discovered under `api-test/src/test/java`. Both
sides now delegate to a shared compiler, with a regression test that
exercises the literal and glob shapes against identical on-disk
layouts.
- `git rm`-only MRs no longer silently skip all tests. `DiffEntry.DELETE`
entries now surface through their old path, so ignore/out-of-scope
rules apply normally and deleted production classes reach the
transitive strategy instead of routing the whole MR through
`EMPTY_DIFF → SKIPPED` under `local`/`ci` mode. The existing engine
filter still drops FQNs whose backing file is gone, so surfacing
deletions never asks Gradle to run a missing test.
- `ImplementationStrategy` now recognises the `DefaultFooService`
prefix shape, not only the `FooServiceImpl` suffix. The plugin has
always shipped `implementationNaming = ["Impl", "Default"]` and the
Javadoc documents both shapes, but the naming-convention loop used
to append both tokens as suffixes (`FooServiceDefault`), matching
nothing real. The AST-scan branch rescued explicit
`implements FooService` cases; generics-only declarations and files
that JavaParser couldn't parse silently missed the Default-prefixed
impl.
- Turkish (and any other locale whose case-folding tables differ from
US English) no longer turn `mode = "ci"` into `Unknown
affectedTests.mode 'ci'`. `parseMode` and `parseAction` now force
`Locale.ROOT`, matching `AffectedTestsConfig`'s own parsing. The
Windows detection in the Gradle-command resolver was pinned to
`Locale.ROOT` for the same reason.
- `PathToClassMapper.isIgnored` and the `OutOfScopeMatchers` glob
matchers now fail closed on `InvalidPathException` (NUL bytes,
Linux-committed filenames like `foo:bar.md` arriving on a Windows
CI runner, Windows reserved names). Before this fix the unhandled
exception killed the whole `affectedTest` task with a stack trace;
now the offending file falls through to the unmapped bucket and the
safety net escalates normally.
- `GitChangeDetector` now translates JGit's `MissingObjectException`
into a targeted message naming the likely cause: a shallow clone in
CI that doesn't know the base ref. Before, users saw the raw JGit
exception and had to guess whether the problem was the ref, the
clone depth, or a corrupt repo.
- Malformed globs in `outOfScopeTestDirs` / `outOfScopeSourceDirs` /
`ignorePaths` now fail at config-time with an
`IllegalStateException` naming the config key, list index, and
offending pattern. The raw `PatternSyntaxException` the JVM throws
is useless on its own because it doesn't say which config entry
caused the regex error.
- The `--tests` argv assembly now skips and warns on any discovered
FQN that isn't shaped like a Java identifier. Defense-in-depth
against a buggy custom strategy or parser anomaly injecting a shell
metacharacter, whitespace, or a hyphen into Gradle's test filter —
such strings either crashed the test runner with an obscure glob-
expansion error or silently matched zero tests.
- Per-FQN dispatch lines in the task output demoted from `lifecycle`
to `info`. Each MR now gets one `lifecycle` summary per Gradle task
(`:api:test (3 test classes)`); the individual FQNs print only when
the user opts in with `--info`. Before, a 200-class MR produced 200
console lines and drowned the single line the user actually cared
about — the outcome/situation summary.

### Documentation

- `Mode` Javadoc now matches the mode-defaults table in the design
doc. The post-v2 table said zero-config CI users get
`DISCOVERY_EMPTY = FULL_SUITE` on top of `LOCAL`, but the class-
level doc still described `LOCAL` as the pre-v2 baseline "minus"
the safety net.
- `Situation` Javadoc cross-reference for `ALL_FILES_OUT_OF_SCOPE`
updated — the old reference pointed at a since-renamed constant.
- `TransitiveStrategy` and `AffectedTestsExtension` now document the
actual `transitiveDepth` default of `4`, not the pre-v2 value of
`2`. Consumers reading Javadoc were being told they needed to set
`transitiveDepth = 4` explicitly; they do not.

## [1.9.12] — 2026-04-22

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
* </table>
*
* <p>The pre-v2 zero-config baseline was
* {@code runAllIfNoMatches=false}, {@code runAllOnNonJavaChange=true} —
* which translates to the {@link #LOCAL} column above minus the
* {@code DISCOVERY_EMPTY=FULL_SUITE} CI safety net. Zero-config users
* running in CI now get the safer default without having to opt in.
* {@code runAllIfNoMatches=false}, {@code runAllOnNonJavaChange=true}
* — which lines up exactly with the {@link #LOCAL} column above. The
* {@link #CI} profile adds the {@code DISCOVERY_EMPTY=FULL_SUITE}
* safety net on top of that so zero-config users who land in a CI
* environment get the safer default without having to opt in.
*/
public enum Mode {
/** Detect CI vs. local at build() time based on common CI env vars. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public enum Situation {
/**
* Every file in the diff matched {@link AffectedTestsConfig#ignorePaths()}
* (or the legacy {@code excludePaths} shim). Distinct from
* {@link #ALL_OUT_OF_SCOPE} so users can treat "purely docs changes"
* {@link #ALL_FILES_OUT_OF_SCOPE} so users can treat "purely docs changes"
* differently from "purely api-test changes".
*/
ALL_FILES_IGNORED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,27 @@ private Set<String> findImplementations(Set<String> changedClasses,
simpleNameToFqns.computeIfAbsent(SourceFileScanner.simpleClassName(fqn), k -> new HashSet<>()).add(fqn);
}

// 1. Naming convention: look for *Impl classes
for (String suffix : config.implementationNaming()) {
for (String fqn : changedClasses) {
String implSimpleName = SourceFileScanner.simpleClassName(fqn) + suffix;
// 1. Naming convention: look for both suffix (*Impl) and prefix
// (Default*) derivatives of the changed interface name. The
// config list ships with {"Impl", "Default"} and the Builder
// javadoc promises both shapes; before this loop checked both
// sides, "Default" was appended as a suffix (FooServiceDefault),
// which matches nothing real — Spring/Guice code writes
// DefaultFooService. The AST branch below rescues the clean
// "implements FooService" case, but impls that declare the
// super-type via generics only, or files JavaParser could not
// parse, were silently missed.
Set<String> changedSimpleNames = new HashSet<>();
for (String fqn : changedClasses) {
changedSimpleNames.add(SourceFileScanner.simpleClassName(fqn));
}
for (String token : config.implementationNaming()) {
for (String changedSimple : changedSimpleNames) {
String suffixShape = changedSimple + token;
String prefixShape = token + changedSimple;
for (String sourceFqn : allSourceFqns) {
if (SourceFileScanner.simpleClassName(sourceFqn).equals(implSimpleName)) {
String sourceSimple = SourceFileScanner.simpleClassName(sourceFqn);
if (sourceSimple.equals(suffixShape) || sourceSimple.equals(prefixShape)) {
implementations.add(sourceFqn);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import com.github.javaparser.ParseResult;
import com.github.javaparser.ast.CompilationUnit;
import io.affectedtests.core.config.AffectedTestsConfig;
import io.affectedtests.core.mapping.OutOfScopeMatchers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.file.Path;
import java.util.*;
import java.util.function.Predicate;

/**
* Pre-scanned index of source and test files for a project directory.
Expand Down Expand Up @@ -43,40 +45,51 @@ private ProjectIndex(List<Path> sourceFiles, List<Path> testFiles,
public static ProjectIndex build(Path projectDir, AffectedTestsConfig config) {
log.info("Building project index for {}", projectDir);

List<String> oosSource = config.outOfScopeSourceDirs();
List<String> oosTest = config.outOfScopeTestDirs();
// Share the exact matcher compilation PathToClassMapper uses so
// diff-side bucketing and indexed-file-side filtering agree on
// every entry — glob form, literal form, or mixed. Before this
// shared source of truth the two sides silently disagreed on
// glob entries: the mapper compiled "api-test/**" into a
// PathMatcher while this class treated it as a literal prefix
// and matched nothing, so mixed diffs still dispatched tests
// under "api-test/".
List<Predicate<String>> oosSourceMatchers = OutOfScopeMatchers.compile(
config.outOfScopeSourceDirs(), "outOfScopeSourceDirs");
List<Predicate<String>> oosTestMatchers = OutOfScopeMatchers.compile(
config.outOfScopeTestDirs(), "outOfScopeTestDirs");
boolean hasOutOfScope = !oosSourceMatchers.isEmpty() || !oosTestMatchers.isEmpty();

List<Path> sourceFiles = filterOutOfScope(
SourceFileScanner.collectSourceFiles(projectDir, config.sourceDirs()),
projectDir, oosSource, oosTest);
projectDir, oosSourceMatchers, oosTestMatchers, hasOutOfScope);
List<Path> testFiles = filterOutOfScope(
SourceFileScanner.collectTestFiles(projectDir, config.testDirs()),
projectDir, oosSource, oosTest);
projectDir, oosSourceMatchers, oosTestMatchers, hasOutOfScope);

LinkedHashMap<String, Path> testFqnToPath = SourceFileScanner.scanTestFqnsWithFiles(
projectDir, config.testDirs());
if (!oosSource.isEmpty() || !oosTest.isEmpty()) {
if (hasOutOfScope) {
// Drop out-of-scope test FQNs from the dispatch map. Without
// this, discovery strategies could still return FQNs living
// under {@code api-test/src/test/java} and the task would then
// try to run them — the entire point of the out-of-scope knob
// is that those tests never reach the affected-test dispatch.
testFqnToPath.entrySet().removeIf(entry -> isUnderAny(
entry.getValue(), projectDir, oosSource, oosTest));
entry.getValue(), projectDir, oosSourceMatchers, oosTestMatchers));
}

Set<String> sourceFqns = new LinkedHashSet<>();
for (String sourceDir : config.sourceDirs()) {
for (Path resolved : SourceFileScanner.findAllMatchingDirs(projectDir, sourceDir)) {
if (isUnderAny(resolved, projectDir, oosSource, oosTest)) continue;
if (hasOutOfScope && isUnderAny(resolved, projectDir, oosSourceMatchers, oosTestMatchers)) continue;
sourceFqns.addAll(SourceFileScanner.fqnsUnder(resolved));
}
}

log.info("Project index: {} source files, {} test files, {} source FQNs, {} test FQNs"
+ " (out-of-scope source dirs: {}, out-of-scope test dirs: {})",
sourceFiles.size(), testFiles.size(), sourceFqns.size(), testFqnToPath.size(),
oosSource.size(), oosTest.size());
config.outOfScopeSourceDirs().size(), config.outOfScopeTestDirs().size());

return new ProjectIndex(
Collections.unmodifiableList(sourceFiles),
Expand All @@ -87,47 +100,42 @@ public static ProjectIndex build(Path projectDir, AffectedTestsConfig config) {
}

private static List<Path> filterOutOfScope(List<Path> files, Path projectDir,
List<String> oosSource, List<String> oosTest) {
if (oosSource.isEmpty() && oosTest.isEmpty()) {
List<Predicate<String>> oosSourceMatchers,
List<Predicate<String>> oosTestMatchers,
boolean hasOutOfScope) {
if (!hasOutOfScope) {
return files;
}
List<Path> filtered = new ArrayList<>(files.size());
for (Path file : files) {
if (!isUnderAny(file, projectDir, oosSource, oosTest)) {
if (!isUnderAny(file, projectDir, oosSourceMatchers, oosTestMatchers)) {
filtered.add(file);
}
}
return filtered;
}

/**
* Normalised, boundary-aware "does this absolute path sit under any of
* the given project-relative dirs?" check. Mirrors
* {@link io.affectedtests.core.mapping.PathToClassMapper} semantics so
* a diff file and an indexed file that point to the same location are
* routed the same way.
* Normalised "does this absolute path sit under any of the compiled
* out-of-scope matchers?" check. Evaluates exactly the matchers
* {@link io.affectedtests.core.mapping.PathToClassMapper} uses on
* the diff side, so a file and an indexed file pointing to the
* same location route the same way whether the entry was written
* as {@code api-test}, {@code api-test/**}, or {@code **&#47;api-test/**}.
*/
static boolean isUnderAny(Path file, Path projectDir, List<String> oosSource, List<String> oosTest) {
if (oosSource.isEmpty() && oosTest.isEmpty()) return false;
static boolean isUnderAny(Path file, Path projectDir,
List<Predicate<String>> oosSourceMatchers,
List<Predicate<String>> oosTestMatchers) {
if (oosSourceMatchers.isEmpty() && oosTestMatchers.isEmpty()) return false;
String rel;
try {
rel = projectDir.toAbsolutePath().relativize(file.toAbsolutePath()).toString();
} catch (IllegalArgumentException e) {
return false;
}
String normalized = rel.replace(java.io.File.separatorChar, '/');
return startsWithAny(normalized, oosSource) || startsWithAny(normalized, oosTest);
}

private static boolean startsWithAny(String normalized, List<String> dirs) {
for (String dir : dirs) {
if (dir == null || dir.isBlank()) continue;
String d = dir.replace('\\', '/');
if (!d.endsWith("/")) d += "/";
if (normalized.startsWith(d)) return true;
if (normalized.contains("/" + d)) return true;
}
return false;
return OutOfScopeMatchers.matchesAny(normalized, oosSourceMatchers)
|| OutOfScopeMatchers.matchesAny(normalized, oosTestMatchers);
}

public List<Path> sourceFiles() { return sourceFiles; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* changes, walks this "used-by" graph N levels deep to find consumers, then
* discovers tests for those consumers via the naming and usage strategies.
* <p>
* Depth is configurable via {@code transitiveDepth} (default 2, max 5).
* Depth is configurable via {@code transitiveDepth} (default 4, max 5).
*/
public final class TransitiveStrategy implements TestDiscoveryStrategy {

Expand Down
Loading