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
171 changes: 129 additions & 42 deletions README.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.affectedtests.core.config;

/**
* What the plugin should do when a given {@link Situation} fires.
*
* <p>The three actions are exhaustive — every decision branch in the engine
* resolves to exactly one of them — and are the primary knob users reach for
* in v2 to express intent without having to know which internal flag used to
* drive that outcome.
*
* <ul>
* <li>{@link #SELECTED} — execute only the tests discovered by the pipeline.
* On branches where discovery never produced anything (e.g. empty diff)
* this degenerates to "no tests run" because the selection set is
* empty by definition; it is distinct from {@link #SKIPPED} so that
* logs and the {@code --explain} trace can still name the situation
* honestly (selection succeeded, set happened to be empty) rather than
* claim the run was skipped.</li>
* <li>{@link #FULL_SUITE} — flip to running every test the project knows
* about. The legacy {@code runAllIfNoMatches} / {@code runAllOnNonJavaChange}
* booleans map onto this action via the shim in
* {@link AffectedTestsConfig.Builder#build()}.</li>
* <li>{@link #SKIPPED} — run no tests at all. Previously impossible to
* express without also disabling the plugin; the situation-specific
* knobs in v2 make it first-class.</li>
* </ul>
*/
public enum Action {
/** Execute the discovered (possibly empty) affected-test selection. */
SELECTED,

/** Flip to running the full test suite. */
FULL_SUITE,

/** Do not run any tests for this invocation. */
SKIPPED
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.affectedtests.core.config;

/**
* Execution profile that seeds the per-situation {@link Action} defaults.
*
* <p>{@link #AUTO} probes the environment (via
* {@link AffectedTestsConfig.Builder#build()}) and resolves to either
* {@link #LOCAL} or {@link #CI} based on the usual CI env vars; every other
* mode is an explicit opt-in and its defaults are applied verbatim.
*
* <p>Defaults per mode (used only when the caller has set neither the
* specific situation action nor the legacy boolean that would translate to
* it):
*
* <table>
* <caption>Per-mode action defaults</caption>
* <tr><th></th><th>EMPTY_DIFF</th><th>ALL_IGNORED</th><th>ALL_OUT_OF_SCOPE</th><th>UNMAPPED_FILE</th><th>DISCOVERY_EMPTY</th></tr>
* <tr><td>LOCAL</td><td>SKIPPED</td><td>SKIPPED</td><td>SKIPPED</td><td>FULL_SUITE</td><td>SKIPPED</td></tr>
* <tr><td>CI</td><td>SKIPPED</td><td>SKIPPED</td><td>SKIPPED</td><td>FULL_SUITE</td><td>FULL_SUITE</td></tr>
* <tr><td>STRICT</td><td>FULL_SUITE</td><td>FULL_SUITE</td><td>SKIPPED</td><td>FULL_SUITE</td><td>FULL_SUITE</td></tr>
* </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.
*/
public enum Mode {
/** Detect CI vs. local at build() time based on common CI env vars. */
AUTO,

/** Developer defaults: skip more, run fewer tests by default. */
LOCAL,

/** CI defaults: run full suite on ambiguity (unmapped, discovery empty). */
CI,

/** Tightest defaults: also escalate ALL_IGNORED to FULL_SUITE. */
STRICT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package io.affectedtests.core.config;

/**
* One of the named decision branches the engine can end up on. Every engine
* run resolves to exactly one situation, which is then mapped to an
* {@link Action} via the per-situation config in
* {@link AffectedTestsConfig}.
*
* <p>The six situations are deliberately exhaustive and mutually exclusive,
* so a human reading the {@code --explain} output never has to reconstruct
* which of several overlapping "safety net" branches the engine actually
* took. The priority in {@link io.affectedtests.core.AffectedTestsEngine}
* evaluates them in a fixed order — empty diff, all-ignored,
* all-out-of-scope, unmapped, discovery — so any diff maps to a single
* situation even when it nominally touches several buckets.
*
* <p>Note: {@link io.affectedtests.core.mapping.PathToClassMapper} already
* routes a file to at most one of its buckets (ignore, out-of-scope,
* production, test, unmapped) by evaluating the same rules in the same
* order, so the "all X" situations and {@link #UNMAPPED_FILE} are by
* construction non-overlapping — there is no diff that is simultaneously
* "all ignored" and "contains an unmapped file".
*/
public enum Situation {
/** Git diff yielded zero changed files. */
EMPTY_DIFF,

/**
* The diff contained at least one file that the mapper could not resolve
* to a Java class under the configured source/test dirs, after honouring
* ignore and out-of-scope rules. The classic example is
* {@code application.yml} or {@code build.gradle}.
*/
UNMAPPED_FILE,

/**
* 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"
* differently from "purely api-test changes".
*/
ALL_FILES_IGNORED,

/**
* Every file in the diff sat under {@link AffectedTestsConfig#outOfScopeTestDirs()}
* or {@link AffectedTestsConfig#outOfScopeSourceDirs()}. Primary motivator:
* repos that want {@code api-test} / Cucumber / performance-test source
* sets to silently skip {@code unitTest} dispatch instead of forcing a
* full suite.
*/
ALL_FILES_OUT_OF_SCOPE,

/**
* Discovery ran, but produced an empty test selection — either no
* production change had any matching test by any strategy, or the diff
* contained only production files with no test coverage at all. This is
* the post-discovery counterpart to {@link #EMPTY_DIFF}.
*/
DISCOVERY_EMPTY,

/** Discovery ran and produced a non-empty test selection. */
DISCOVERY_SUCCESS
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,40 @@ private ProjectIndex(List<Path> sourceFiles, List<Path> testFiles,
public static ProjectIndex build(Path projectDir, AffectedTestsConfig config) {
log.info("Building project index for {}", projectDir);

List<Path> sourceFiles = SourceFileScanner.collectSourceFiles(projectDir, config.sourceDirs());
List<Path> testFiles = SourceFileScanner.collectTestFiles(projectDir, config.testDirs());
List<String> oosSource = config.outOfScopeSourceDirs();
List<String> oosTest = config.outOfScopeTestDirs();

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

LinkedHashMap<String, Path> testFqnToPath = SourceFileScanner.scanTestFqnsWithFiles(
projectDir, config.testDirs());
if (!oosSource.isEmpty() || !oosTest.isEmpty()) {
// 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));
}

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

log.info("Project index: {} source files, {} test files, {} source FQNs, {} test FQNs",
sourceFiles.size(), testFiles.size(), sourceFqns.size(), testFqnToPath.size());
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());

return new ProjectIndex(
Collections.unmodifiableList(sourceFiles),
Expand All @@ -66,6 +86,50 @@ 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()) {
return files;
}
List<Path> filtered = new ArrayList<>(files.size());
for (Path file : files) {
if (!isUnderAny(file, projectDir, oosSource, oosTest)) {
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.
*/
static boolean isUnderAny(Path file, Path projectDir, List<String> oosSource, List<String> oosTest) {
if (oosSource.isEmpty() && oosTest.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;
}

public List<Path> sourceFiles() { return sourceFiles; }
public List<Path> testFiles() { return testFiles; }
public Set<String> testFqns() { return testFqnToPath.keySet(); }
Expand Down
Loading