diff --git a/README.md b/README.md
index bf53ccd..6edec09 100644
--- a/README.md
+++ b/README.md
@@ -24,10 +24,12 @@ plugins {
```
That's it. With zero config, the plugin will:
-- Diff against `origin/master`
-- Include uncommitted and staged changes
-- Use naming, usage, implementation, and transitive strategies to discover affected tests
-- Follow 2 levels of transitive dependencies
+
+- Diff against `origin/master` (including uncommitted + staged changes).
+- Route each changed file through one of five buckets: **ignored** (`*.md`, LICENSE, CHANGELOG, images, `**/generated/**`), **out-of-scope**, **production `.java`**, **test `.java`**, or **unmapped** (everything else, e.g. `application.yml`).
+- Pick a discovery strategy — **naming**, **usage**, **impl**, **transitive** — and merge their results into one test set.
+- Follow 4 levels of transitive dependencies (tuned for typical controller → service → repository chains).
+- Fall through to the full suite if it encounters an unmapped file, so a YAML/Gradle/Liquibase diff never ships without tests.
## CI Integration
@@ -45,9 +47,47 @@ That's it. With zero config, the plugin will:
> Make sure to use `fetch-depth: 0` so `git diff` has access to the full history.
+## The v2 decision model
+
+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.
+
+### Situations (what the engine saw)
+
+| Situation | Fires when |
+|---|---|
+| `EMPTY_DIFF` | `git diff` produced no files at all. |
+| `ALL_FILES_IGNORED` | Every file in the diff matched `ignorePaths` (e.g. a docs-only MR). |
+| `ALL_FILES_OUT_OF_SCOPE` | Every file sat under `outOfScopeTestDirs` or `outOfScopeSourceDirs` (e.g. a Cucumber/api-test-only MR). |
+| `UNMAPPED_FILE` | The diff contains at least one file the plugin cannot resolve to a Java class under `sourceDirs`/`testDirs` (e.g. `application.yml`, `build.gradle`, a Liquibase changelog). |
+| `DISCOVERY_EMPTY` | Mapping succeeded but the discovery strategies returned zero tests. |
+| `DISCOVERY_SUCCESS` | Mapping + discovery produced a non-empty test set. |
+
+### Actions (what the engine will do)
+
+| Action | Meaning |
+|---|---|
+| `SELECTED` | Run only the discovered affected tests. |
+| `FULL_SUITE` | Run the entire test suite (no `--tests` filter). |
+| `SKIPPED` | Exit 0 without running tests. |
+
+Every situation gets an independently-configurable action. The matrix is resolved in strict priority order: **explicit `onXxx`** setting → **legacy boolean** (`runAllIfNoMatches` / `runAllOnNonJavaChange`) → **`mode` profile default** → **pre-v2 hardcoded default**. So nothing you configure today silently regresses tomorrow.
+
+### Mode profiles
+
+`mode` seeds the defaults for situations you haven't explicitly configured:
+
+| Mode | `EMPTY_DIFF` | `ALL_FILES_IGNORED` | `ALL_FILES_OUT_OF_SCOPE` | `UNMAPPED_FILE` | `DISCOVERY_EMPTY` |
+|---|---|---|---|---|---|
+| `local` | SKIPPED | SKIPPED | SKIPPED | FULL_SUITE | SKIPPED |
+| `ci` | SKIPPED | SKIPPED | SKIPPED | FULL_SUITE | **FULL_SUITE** |
+| `strict` | FULL_SUITE | FULL_SUITE | SKIPPED | FULL_SUITE | FULL_SUITE |
+| `auto` | Detects `CI` / `GITHUB_ACTIONS` / `GITLAB_CI` / `JENKINS_HOME` and resolves to `ci` or `local`. |
+
+Leaving `mode` unset keeps the pre-v2 zero-config behaviour (same as `local` plus the legacy `runAllOnNonJavaChange=true` safety net).
+
## Configuration
-All settings have sensible defaults. Override only what you need:
+All settings have sensible defaults. Override only what you need.
```groovy
affectedTests {
@@ -58,29 +98,62 @@ affectedTests {
includeUncommitted = true
includeStaged = true
- // Run the full suite whenever there is nothing specific to run — either
- // because the diff produced no changed files at all OR because discovery
- // ran and returned an empty set. The two branches produce distinct
- // escalation reasons in the CI log so an operator can tell them apart.
- // Default: false (skip tests silently in both cases).
+ // v2 profile. "auto" is the recommended migration target.
+ // See the "Mode profiles" table above.
+ // (default: unset — preserves pre-v2 defaults)
+ mode = "auto"
+
+ // ---------------- Path buckets (v2) ----------------
+
+ // Files that must not influence test selection (docs, LICENSE,
+ // CHANGELOG, images, generated sources). When every file in the
+ // diff matches ignorePaths, the engine lands on ALL_FILES_IGNORED.
+ // The defaults already cover markdown/text/LICENSE/CHANGELOG/images
+ // at both the repo root and nested paths — you usually only extend this.
+ ignorePaths = ["**/*Dto.java"]
+
+ // 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"]
+
+ // Production source sets the plugin must treat as out-of-scope.
+ outOfScopeSourceDirs = []
+
+ // Back-compat alias for ignorePaths. Still honoured so existing
+ // configs keep working. (default: [])
+ // excludePaths = ["**/generated/**"]
+
+ // ---------------- Per-situation actions (v2) ----------------
+
+ // Each takes one of "selected" | "full_suite" | "skipped".
+ // Any value left unset falls back through mode → pre-v2 default.
+ onEmptyDiff = "skipped"
+ onAllFilesIgnored = "skipped"
+ onAllFilesOutOfScope = "skipped"
+ onUnmappedFile = "full_suite" // the key MR-safety knob
+ onDiscoveryEmpty = "full_suite" // belt-and-braces for CI
+
+ // ---------------- Legacy booleans (still supported) ----------------
+
+ // Translated into onEmptyDiff + onDiscoveryEmpty at build() time.
+ // (default: false)
runAllIfNoMatches = false
- // Force a full run when the diff contains any file we cannot resolve to
- // a Java class under sourceDirs/testDirs. This covers both non-Java
- // resources (application.yml, build.gradle, Liquibase changelogs,
- // logback config) AND stray .java files living outside the configured
- // source/test dirs (e.g. buildSrc sources when buildSrc is not in
- // sourceDirs). Excluded paths are honoured and do not trigger the
- // escalation. This is independent of runAllIfNoMatches — the two
- // safety nets fire on different conditions. Default: true.
+ // Translated into onUnmappedFile at build() time.
+ // (default: true — "run more, never run less")
runAllOnNonJavaChange = true
+ // ---------------- Discovery tuning ----------------
+
// Discovery strategies: "naming", "usage", "impl", "transitive" (default: all four)
strategies = ["naming", "usage", "impl", "transitive"]
- // Transitive dependency depth — used when the "transitive" strategy is enabled
- // (default: 2, max: 5, 0 = disabled)
- transitiveDepth = 2
+ // Transitive dependency depth — used when the "transitive" strategy is enabled.
+ // Raised from 2 → 4 in v2 because typical Spring controller→service→repo
+ // chains are 2–3 deep; 4 gives a margin without producing runaway sets.
+ // (default: 4, max: 5, 0 = disabled)
+ transitiveDepth = 4
// Test class suffixes (default: ["Test", "IT", "ITTest", "IntegrationTest"])
testSuffixes = ["Test", "IT", "ITTest", "IntegrationTest"]
@@ -91,23 +164,22 @@ affectedTests {
// Test directories (default: ["src/test/java"])
testDirs = ["src/test/java"]
- // Exclude patterns (default: ["**/generated/**"])
- excludePaths = ["**/generated/**", "**/*Dto.java"]
-
// Include tests for implementations of changed interfaces (default: true)
includeImplementationTests = true
- // Implementation naming suffixes (default: ["Impl"])
- implementationNaming = ["Impl"]
+ // Implementation naming prefixes/suffixes — "Impl" matches FooImpl for Foo;
+ // "Default" matches DefaultFoo for Foo, which is idiomatic in Spring code.
+ // (default: ["Impl", "Default"])
+ implementationNaming = ["Impl", "Default"]
}
```
## How It Works
-The pipeline is four stages: **detect** what changed, **map** each path to a Java class (or declare it unmappable), **discover** the tests impacted by those classes, and **execute** only that subset — or the full suite if safety rules demand it.
+The pipeline is five stages: **detect** what changed, **bucket** each path (ignored / out-of-scope / production / test / unmapped), **resolve** the `Situation`, **discover** the tests impacted by the in-scope Java classes, and **execute** only that subset — or the full suite, or nothing at all — based on the `Action` the `Situation` maps to.
-
+
Source: [`docs/architecture.mmd`](docs/architecture.mmd) · regenerate with `npx --yes @mermaid-js/mermaid-cli -i docs/architecture.mmd -o docs/architecture.svg -b transparent`
@@ -120,14 +192,14 @@ All four strategies run against every changed production class. Their results ar
|----------|-------------|---------|
| **naming** | For each changed class `Foo`, looks for test files named `FooTest`, `FooIT`, `FooITTest`, `FooIntegrationTest` (configurable suffixes). Purely file-name based — no parsing required. | `FooService` changed → finds `FooServiceTest`, `FooServiceIT` |
| **usage** | Parses every test file with JavaParser and checks whether it references any changed class. Uses a two-tier approach: **(1)** direct import match — if the test has `import com.example.FooService;`, it's affected regardless of how it uses the class; **(2)** type-reference scan for wildcard imports (`import com.example.*`) and same-package usage (no import needed). Catches fields, method parameters, return types, constructor calls, generics, and casts. | `BarModel` changed → finds `BarValidatorTest` (imports it), `BazMapperTest` (same package, uses it as field type) |
-| **impl** | When an interface or base class changes, scans all production source files to find classes that `extends` or `implements` the changed type (via AST) and classes following the `*Impl` naming convention. Then re-runs the naming and usage strategies on those implementations. | `FooService` (interface) changed → finds `FooServiceImpl` → finds `FooServiceImplTest` |
-| **transitive** | Builds a reverse dependency map of all production classes: for each class, which other classes depend on it (via field types). When a class changes, walks this "used-by" graph N levels deep (configurable, default 2, max 5) to find consumers. Then runs naming + usage on those consumers. | `BazGateway` changed → `FooService` uses it (depth 1) → finds `FooServiceTest` via naming |
+| **impl** | When an interface or base class changes, scans all production source files to find classes that `extends` or `implements` the changed type (via AST) and classes following the `*Impl` or `Default*` naming convention. Then re-runs the naming and usage strategies on those implementations. | `FooService` (interface) changed → finds `FooServiceImpl` and `DefaultFooService` → finds `FooServiceImplTest` / `DefaultFooServiceTest` |
+| **transitive** | Builds a reverse dependency map of all production classes: for each class, which other classes depend on it (via field types). When a class changes, walks this "used-by" graph N levels deep (configurable, default 4, max 5) to find consumers. Then runs naming + usage on those consumers. | `BazGateway` changed → `FooService` uses it (depth 1) → `OrdersController` uses `FooService` (depth 2) → finds both tests via naming |
### How scanning works
The plugin scans the project tree **recursively at any depth** to find source and test directories. It is completely project-structure agnostic — it does not assume any particular module layout. Whether your modules are flat (`api/src/test/java`), nested (`services/orders/src/test/java`), or deeply nested (`platform/services/orders/src/test/java`), all test files are discovered.
-Directories like `.git`, `build`, `.gradle`, and `node_modules` are automatically skipped during the walk.
+Directories like `.git`, `build`, `.gradle`, and `node_modules` are automatically skipped during the walk. `outOfScopeTestDirs` and `outOfScopeSourceDirs` are additionally filtered at index time so discovery never picks up tests living there.
### Directly changed tests
@@ -146,19 +218,33 @@ Internally, each discovered test FQN is traced back to the Gradle subproject tha
This makes `--tests` filters scope cleanly to their owning module, instead of being applied globally and failing on any subproject that doesn't happen to contain the FQN. Cross-module imports (e.g. a test in `application` that imports a class from `api`) are still detected correctly via the `usage` and `impl` strategies.
-## Fallback Behavior
+## Behaviour reference
+
+Every row below shows the situation the engine resolved, and the action applied with the default configuration (no `mode` set, no explicit `onXxx`).
+
+| Diff contents | Resolved `Situation` | Default `Action` | Override knob |
+|---|---|---|---|
+| Only mapped production/test `.java` files | `DISCOVERY_SUCCESS` (or `DISCOVERY_EMPTY` if no tests map) | `SELECTED` | discovery tuning |
+| Only files matching `ignorePaths` (docs, LICENSE, CHANGELOG, images, generated) | `ALL_FILES_IGNORED` | `SKIPPED` | `onAllFilesIgnored` or `mode=strict` |
+| Only files under `outOfScopeTestDirs` / `outOfScopeSourceDirs` (e.g. api-test only) | `ALL_FILES_OUT_OF_SCOPE` | `SKIPPED` | `onAllFilesOutOfScope` |
+| Any YAML / Gradle / Liquibase / `.java` outside configured dirs | `UNMAPPED_FILE` | `FULL_SUITE` (via `runAllOnNonJavaChange=true`) | `onUnmappedFile = "selected"` / `runAllOnNonJavaChange = false` |
+| No changed files at all | `EMPTY_DIFF` | `SKIPPED` | `onEmptyDiff = "full_suite"` / `runAllIfNoMatches = true` / `mode = strict` |
+| Mapping succeeds but discovery returns zero tests | `DISCOVERY_EMPTY` | `SKIPPED` — or `FULL_SUITE` if `mode=ci`/`strict` or `runAllIfNoMatches=true` | `onDiscoveryEmpty` / `mode` / `runAllIfNoMatches` |
+| Mixed diff: Java + unmapped file | `UNMAPPED_FILE` (takes precedence) | `FULL_SUITE` | `onUnmappedFile` — set to `"selected"` to fall through to discovery |
+| `baseRef` not resolvable | `FAILED` | Hard error (prevents silent test skipping in CI) | — |
+| Not a git work tree / JGit I/O error | `FAILED` | Hard error | — |
+
+The `onUnmappedFile = "full_suite"` default follows the "run more, never run less" principle: a change to `application.yml` can alter production behaviour just as surely as a change to a `.java` file, so the plugin cannot safely pick a subset from an empty Java mapping.
+
+### Migration from pre-v2
+
+Existing configs keep working. Specifically:
-| Scenario | Default behavior |
-|----------|-----------------|
-| Change set contains only mapped Java sources | Run the filtered set of affected tests |
-| Change set contains any non-Java or unmapped file — YAML, Gradle, Liquibase, logback, **or a `.java` file outside the configured `sourceDirs`/`testDirs`** | **Run the full test suite** (via `runAllOnNonJavaChange = true`) — opt out with `runAllOnNonJavaChange = false` to restore silent skip |
-| No changed files at all | Exit 0 (or run full suite if `runAllIfNoMatches = true`) — reported to CI as `runAllIfNoMatches=true — no changed files detected`, distinct from the "discovery ran and found nothing" case below |
-| No matching tests found after discovery | Exit 0 (or run full suite if `runAllIfNoMatches = true`) — reported to CI as `runAllIfNoMatches=true — no affected tests discovered` |
-| Changed file matches an entry in `excludePaths` | Silently ignored — excluded paths are an explicit opt-out and never trigger the non-Java escalation |
-| Base ref not found | **Fails with clear error** (prevents silent test skipping in CI) |
-| Git not available | **Fails with clear error** |
+- `runAllIfNoMatches = true` is translated into `onEmptyDiff = FULL_SUITE` **and** `onDiscoveryEmpty = FULL_SUITE` (the pre-v2 behaviour conflated them).
+- `runAllOnNonJavaChange = true` is translated into `onUnmappedFile = FULL_SUITE`.
+- `excludePaths` continues to work as an alias for `ignorePaths`.
-The `runAllOnNonJavaChange` default follows the "run more, never run less" principle: a change to `application.yml` can alter production behaviour just as surely as a change to a `.java` file, so the plugin cannot safely pick a subset from an empty Java mapping. Projects that prefer the older "silent skip" behaviour can set `runAllOnNonJavaChange = false`.
+The escalation log line names **both** the v2 decision and the legacy flag that would have produced it, so existing grep-based CI dashboards don't break.
## Project Structure
@@ -167,6 +253,7 @@ affected-tests/
├── affected-tests-core/ # Git integration, change detection, test discovery
├── affected-tests-gradle/ # Gradle plugin (io.github.vedanthvdev.affectedtests)
├── docs/
+│ ├── DESIGN-v2.md # v2 design document (situation/action/mode model)
│ ├── architecture.mmd # Mermaid source for the architecture diagram
│ └── architecture.svg # Rendered diagram embedded in README
├── build.gradle
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java b/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java
index 0408edb..ab8fc72 100644
--- a/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/AffectedTestsEngine.java
@@ -1,6 +1,8 @@
package io.affectedtests.core;
+import io.affectedtests.core.config.Action;
import io.affectedtests.core.config.AffectedTestsConfig;
+import io.affectedtests.core.config.Situation;
import io.affectedtests.core.discovery.*;
import io.affectedtests.core.git.GitChangeDetector;
import io.affectedtests.core.mapping.PathToClassMapper;
@@ -14,17 +16,22 @@
/**
* Main orchestrator: detects changes, maps them to classes, discovers affected tests.
*
- *
Pipeline:
+ *
Pipeline (v2 situation-based):
*
*
Detect changed files via JGit ({@code baseRef..HEAD} + uncommitted/staged)
- *
Map file paths to production and test class FQNs
- *
Run all enabled discovery strategies (naming, usage, impl, transitive)
- * and merge their results. Scanning is recursive — modules at any nesting
- * depth are discovered automatically.
+ *
Map file paths into five mutually-exclusive buckets: ignored,
+ * out-of-scope, production, test, unmapped
+ *
Pick the {@link Situation} for the diff (see evaluation order in the
+ * {@link Situation} javadoc). {@link Situation#EMPTY_DIFF} short-circuits
+ * before any discovery runs.
+ *
For "ambiguous" situations (empty diff, all-ignored, all-out-of-scope,
+ * unmapped) the {@link Action} from {@link AffectedTestsConfig#actionFor}
+ * is applied directly — no discovery.
+ *
Otherwise run all enabled discovery strategies (naming, usage, impl,
+ * transitive) and merge their results, then route through
+ * {@link Situation#DISCOVERY_EMPTY} or {@link Situation#DISCOVERY_SUCCESS}.
*
Filter the union against test classes that actually exist on disk so
* deleted/renamed tests don't reach the downstream {@code test} task
- *
Return the filtered FQN set together with the file path of each test,
- * so callers can route per-module test invocations correctly
*
*/
public final class AffectedTestsEngine {
@@ -41,10 +48,10 @@ public AffectedTestsEngine(AffectedTestsConfig config, Path projectDir) {
/**
* Why a result flipped to {@code runAll = true}, or {@link #NONE} when no
- * escalation occurred. Callers (notably the Gradle task) use this to log
- * an accurate trigger name — without it, any escalation would have to be
- * attributed to a single hard-coded flag, which misleads users once
- * multiple escalation paths exist.
+ * escalation occurred. Preserved as a v1 back-compat surface so that
+ * existing Gradle-task callers keep receiving the same reason codes —
+ * the v2 engine now records a richer {@link Situation}/{@link Action}
+ * pair internally and derives the legacy code from them.
*/
public enum EscalationReason {
/** No escalation — either a filtered selection or a plain "nothing to do" result. */
@@ -52,37 +59,65 @@ public enum EscalationReason {
/**
* Git produced an empty change set (no files differ between
* {@code baseRef} and the working tree) and {@code runAllIfNoMatches}
- * was true. Distinct from {@link #RUN_ALL_IF_NO_MATCHES} because
- * discovery never actually ran, and the lifecycle log must say so
- * rather than claim "no affected tests discovered".
+ * was true. Derived from {@link Situation#EMPTY_DIFF} +
+ * {@link Action#FULL_SUITE}.
*/
RUN_ALL_ON_EMPTY_CHANGESET,
- /** Discovery completed, returned an empty test set, and {@code runAllIfNoMatches} was true. */
+ /**
+ * Discovery completed, returned an empty test set, and the action
+ * for {@link Situation#DISCOVERY_EMPTY} resolved to
+ * {@link Action#FULL_SUITE}.
+ */
RUN_ALL_IF_NO_MATCHES,
/**
* The change set contained at least one file the mapper could not
* resolve to a Java class under the configured source/test
- * directories (e.g. {@code application.yml}, {@code build.gradle}),
- * and {@code runAllOnNonJavaChange} was true.
+ * directories, and the action for {@link Situation#UNMAPPED_FILE}
+ * resolved to {@link Action#FULL_SUITE}.
+ */
+ RUN_ALL_ON_NON_JAVA_CHANGE,
+ /**
+ * Every file in the diff matched {@link AffectedTestsConfig#ignorePaths()}
+ * and the action for {@link Situation#ALL_FILES_IGNORED} resolved
+ * to {@link Action#FULL_SUITE}. v2-only — no legacy boolean
+ * produces this code.
+ */
+ RUN_ALL_ON_ALL_FILES_IGNORED,
+ /**
+ * Every file in the diff sat under
+ * {@link AffectedTestsConfig#outOfScopeTestDirs()} or
+ * {@link AffectedTestsConfig#outOfScopeSourceDirs()} and the
+ * action for {@link Situation#ALL_FILES_OUT_OF_SCOPE} resolved to
+ * {@link Action#FULL_SUITE}. v2-only.
*/
- RUN_ALL_ON_NON_JAVA_CHANGE
+ RUN_ALL_ON_ALL_FILES_OUT_OF_SCOPE
}
/**
* Result of the affected tests analysis.
*
* @param testClassFqns FQNs of tests that should be executed
+ * (empty when {@link #runAll} or
+ * {@link #skipped} is true).
* @param testFqnToPath map of test FQN to its absolute file path on
* disk (used by callers to route invocations
* to the correct subproject). Empty when
- * {@link #runAll} is {@code true}.
+ * {@link #runAll} or {@link #skipped} is
+ * {@code true}.
* @param changedFiles raw changed file paths from git
* @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 runAll whether the caller should run the full suite
- * @param escalationReason tag describing why {@code runAll} flipped,
- * or {@link EscalationReason#NONE} when it did not
+ * @param skipped whether the caller should run no tests at all
+ * (v2 — previously impossible to express)
+ * @param situation which decision branch the engine landed on
+ * @param action the resolved {@link Action} for
+ * {@link #situation}; one of SELECTED,
+ * FULL_SUITE, SKIPPED
+ * @param escalationReason legacy reason code kept in sync with
+ * {@link #situation}/{@link #action} for
+ * v1 callers; see {@link EscalationReason}
*/
public record AffectedTestsResult(
Set testClassFqns,
@@ -91,11 +126,16 @@ public record AffectedTestsResult(
Set changedProductionClasses,
Set changedTestClasses,
boolean runAll,
+ boolean skipped,
+ Situation situation,
+ Action action,
EscalationReason escalationReason
) {}
/**
- * Runs the full pipeline: detect changes, map to classes, discover tests.
+ * Runs the full pipeline: detect changes, map to classes, pick a
+ * situation, resolve it to an action, and (where relevant) discover
+ * the affected test set.
*/
public AffectedTestsResult run() {
log.info("=== Affected Tests Analysis ===");
@@ -103,42 +143,54 @@ public AffectedTestsResult run() {
log.info("Base ref: {}", config.baseRef());
log.info("Strategies: {}", config.strategies());
log.info("Transitive depth: {}", config.transitiveDepth());
+ log.info("Effective mode: {}", config.effectiveMode());
GitChangeDetector changeDetector = new GitChangeDetector(projectDir, config);
Set changedFiles = changeDetector.detectChangedFiles();
if (changedFiles.isEmpty()) {
log.info("No changed files detected.");
- boolean runAll = config.runAllIfNoMatches();
- // Distinct reason from the post-discovery empty path: discovery
- // never ran here, so the task log must not claim "no affected
- // tests discovered" when in fact nothing was ever looked at.
- return new AffectedTestsResult(Set.of(), Map.of(), changedFiles, Set.of(), Set.of(),
- runAll,
- runAll ? EscalationReason.RUN_ALL_ON_EMPTY_CHANGESET : EscalationReason.NONE);
+ return resolveAmbiguous(Situation.EMPTY_DIFF, changedFiles,
+ Set.of(), Set.of());
}
PathToClassMapper mapper = new PathToClassMapper(config);
MappingResult mapping = mapper.mapChangedFiles(changedFiles);
- // Safety escalation: if the caller opted in (default) and any changed
- // file cannot be mapped to a Java class under the configured source
- // dirs — typically application.yml, build.gradle, a Liquibase
- // changelog, a logback config — we refuse to pick a subset. The
- // motto is "run more, never run less": the filtered set is empty and
- // runAll is true so the downstream task executes the whole suite.
- if (config.runAllOnNonJavaChange() && !mapping.unmappedChangedFiles().isEmpty()) {
- log.warn("Non-Java / unmapped change detected ({} file(s)). Forcing full test suite. Examples: {}",
- mapping.unmappedChangedFiles().size(),
+ int diffSize = changedFiles.size();
+ int ignored = mapping.ignoredFiles().size();
+ int outOfScope = mapping.outOfScopeFiles().size();
+
+ // Priority matches the Situation javadoc. Remember that the mapper
+ // already routes each file into at most one bucket, so the "all X"
+ // branches and the "any unmapped" branch are mutually exclusive by
+ // construction. The order here just picks the situation name that
+ // matches the diff's shape.
+ if (ignored == diffSize) {
+ log.info("All {} changed file(s) matched ignorePaths.", diffSize);
+ return resolveAmbiguous(Situation.ALL_FILES_IGNORED, changedFiles,
+ mapping.productionClasses(), mapping.testClasses());
+ }
+ 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());
+ }
+ if (!mapping.unmappedChangedFiles().isEmpty()) {
+ Action action = config.actionFor(Situation.UNMAPPED_FILE);
+ log.warn("Non-Java / unmapped change detected ({} file(s)). Action: {}. Examples: {}",
+ mapping.unmappedChangedFiles().size(), action,
mapping.unmappedChangedFiles().stream().limit(5).toList());
- return new AffectedTestsResult(
- Set.of(),
- Map.of(),
- changedFiles,
- mapping.productionClasses(),
- mapping.testClasses(),
- true,
- EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
+ // SELECTED here means "ignore the unmapped file, proceed with
+ // discovery on whatever production/test files were in the
+ // diff" — this is the behaviour legacy
+ // {@code runAllOnNonJavaChange=false} callers expect, and the
+ // only way to express it in the v2 model without inventing a
+ // second fallthrough enum value.
+ if (action != Action.SELECTED) {
+ return emptyResult(Situation.UNMAPPED_FILE, action, changedFiles,
+ mapping.productionClasses(), mapping.testClasses());
+ }
}
Set candidateTests = new LinkedHashSet<>();
@@ -184,14 +236,11 @@ public AffectedTestsResult run() {
}
}
- boolean runAll = false;
- EscalationReason reason = EscalationReason.NONE;
- if (allTestsToRun.isEmpty() && config.runAllIfNoMatches()) {
- log.warn("No affected tests found but runAllIfNoMatches=true. Running full suite.");
- runAll = true;
- reason = EscalationReason.RUN_ALL_IF_NO_MATCHES;
- } else if (allTestsToRun.isEmpty()) {
- log.info("No affected tests found. Nothing to run.");
+ if (allTestsToRun.isEmpty()) {
+ 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());
}
log.info("=== Result: {} affected test classes ===", allTestsToRun.size());
@@ -203,8 +252,76 @@ public AffectedTestsResult run() {
changedFiles,
mapping.productionClasses(),
mapping.testClasses(),
+ false,
+ false,
+ Situation.DISCOVERY_SUCCESS,
+ Action.SELECTED,
+ EscalationReason.NONE
+ );
+ }
+
+ /**
+ * Resolves a situation that short-circuits discovery into an empty
+ * result with the appropriate {@code runAll}/{@code skipped} flags.
+ * Used by every branch except {@link Situation#DISCOVERY_SUCCESS} and
+ * {@link Situation#UNMAPPED_FILE}-with-{@link Action#SELECTED}.
+ *
+ *
When {@link Situation#UNMAPPED_FILE} resolves to
+ * {@link Action#SELECTED} the engine deliberately does not
+ * route through here — it continues into discovery so the diff's
+ * Java files still get analysed, matching the pre-v2 behaviour of
+ * {@code runAllOnNonJavaChange=false}.
+ */
+ private AffectedTestsResult resolveAmbiguous(Situation situation,
+ Set changedFiles,
+ Set changedProduction,
+ Set changedTests) {
+ Action action = config.actionFor(situation);
+ if (action == Action.SELECTED) {
+ // The only meaningful way for SELECTED to reach here is
+ // someone explicitly configured {@code onEmptyDiff(SELECTED)}
+ // or similar. In every "ambiguous" branch there is no
+ // selection to run by definition, so SELECTED collapses to
+ // "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);
+ }
+
+ private AffectedTestsResult emptyResult(Situation situation, Action action,
+ Set changedFiles,
+ Set changedProduction,
+ Set changedTests) {
+ 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 —
+ // but the {@link Action} field on the result still reads SELECTED
+ // so {@code --explain} can report honestly.
+ boolean skipped = action == Action.SKIPPED
+ || (action == Action.SELECTED && situation != Situation.DISCOVERY_SUCCESS);
+ return new AffectedTestsResult(
+ Set.of(),
+ Map.of(),
+ changedFiles,
+ changedProduction,
+ changedTests,
runAll,
- reason
+ skipped,
+ situation,
+ action,
+ legacyReason(situation, action)
);
}
+
+ private static EscalationReason legacyReason(Situation situation, Action action) {
+ if (action != Action.FULL_SUITE) return EscalationReason.NONE;
+ return switch (situation) {
+ case EMPTY_DIFF -> EscalationReason.RUN_ALL_ON_EMPTY_CHANGESET;
+ case DISCOVERY_EMPTY -> EscalationReason.RUN_ALL_IF_NO_MATCHES;
+ case UNMAPPED_FILE -> EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE;
+ case ALL_FILES_IGNORED -> EscalationReason.RUN_ALL_ON_ALL_FILES_IGNORED;
+ case ALL_FILES_OUT_OF_SCOPE -> EscalationReason.RUN_ALL_ON_ALL_FILES_OUT_OF_SCOPE;
+ case DISCOVERY_SUCCESS -> EscalationReason.NONE;
+ };
+ }
}
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/Action.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/Action.java
new file mode 100644
index 0000000..2fafa8a
--- /dev/null
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/Action.java
@@ -0,0 +1,37 @@
+package io.affectedtests.core.config;
+
+/**
+ * What the plugin should do when a given {@link Situation} fires.
+ *
+ *
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.
+ *
+ *
+ *
{@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.
+ *
{@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()}.
+ *
{@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.
+ *
+ */
+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
+}
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java
index 1ae9319..a49bb06 100644
--- a/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/AffectedTestsConfig.java
@@ -1,12 +1,41 @@
package io.affectedtests.core.config;
+import java.util.EnumMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Configuration for affected test detection.
* Immutable value object — use the {@link Builder} to construct.
+ *
+ *
v2 situation-based config
+ * The legacy booleans {@code runAllIfNoMatches} and {@code runAllOnNonJavaChange}
+ * remain supported for back-compat and map onto the v2
+ * {@link Situation} / {@link Action} model via a translation shim in
+ * {@link Builder#build()}:
+ *
+ *
+ *
{@code runAllIfNoMatches=true} → every "no affected tests" branch
+ * ({@link Situation#EMPTY_DIFF}, {@link Situation#ALL_FILES_IGNORED},
+ * {@link Situation#ALL_FILES_OUT_OF_SCOPE},
+ * {@link Situation#DISCOVERY_EMPTY}) escalates to {@link Action#FULL_SUITE}.
+ *
{@code runAllIfNoMatches=false} → the same four branches resolve
+ * to {@link Action#SKIPPED} (i.e. run no tests), matching the pre-v2
+ * "silent no-op" behaviour when discovery turned up nothing.
{@code runAllOnNonJavaChange=false} → {@link Situation#UNMAPPED_FILE}
+ * resolves to {@link Action#SELECTED}, meaning "treat the unmapped file
+ * as if it hadn't been there and continue to discovery". That matches
+ * the pre-v2 "silent skip on YAML" behaviour exactly.
+ *
An explicit {@code onXxx()} call always wins over the legacy boolean
+ * translation, and the legacy boolean translation always wins over the
+ * {@link Mode}-based defaults. Users upgrading can therefore adopt the
+ * new DSL branch-by-branch without having to delete their legacy
+ * config in a single step.
+ *
*/
public final class AffectedTestsConfig {
@@ -26,24 +55,164 @@ public final class AffectedTestsConfig {
private final List testSuffixes;
private final List sourceDirs;
private final List testDirs;
- private final List excludePaths;
+ private final List ignorePaths;
+ private final List outOfScopeTestDirs;
+ private final List outOfScopeSourceDirs;
private final boolean includeImplementationTests;
private final List implementationNaming;
+ private final Mode mode;
+ private final Mode effectiveMode;
+ private final Map situationActions;
private AffectedTestsConfig(Builder builder) {
this.baseRef = builder.baseRef;
this.includeUncommitted = builder.includeUncommitted;
this.includeStaged = builder.includeStaged;
- this.runAllIfNoMatches = builder.runAllIfNoMatches;
- this.runAllOnNonJavaChange = builder.runAllOnNonJavaChange;
this.strategies = Set.copyOf(builder.strategies);
this.transitiveDepth = builder.transitiveDepth;
this.testSuffixes = List.copyOf(builder.testSuffixes);
this.sourceDirs = List.copyOf(builder.sourceDirs);
this.testDirs = List.copyOf(builder.testDirs);
- this.excludePaths = List.copyOf(builder.excludePaths);
this.includeImplementationTests = builder.includeImplementationTests;
this.implementationNaming = List.copyOf(builder.implementationNaming);
+
+ // Resolve the paths-to-ignore list. Precedence matches the doc:
+ // explicit ignorePaths() > explicit excludePaths() > builder default.
+ List resolvedIgnore;
+ if (builder.ignorePaths != null) {
+ resolvedIgnore = List.copyOf(builder.ignorePaths);
+ } else if (builder.excludePaths != null) {
+ resolvedIgnore = List.copyOf(builder.excludePaths);
+ } else {
+ resolvedIgnore = List.copyOf(Builder.DEFAULT_IGNORE_PATHS);
+ }
+ this.ignorePaths = resolvedIgnore;
+
+ this.outOfScopeTestDirs = List.copyOf(
+ builder.outOfScopeTestDirs != null ? builder.outOfScopeTestDirs : List.of());
+ this.outOfScopeSourceDirs = List.copyOf(
+ builder.outOfScopeSourceDirs != null ? builder.outOfScopeSourceDirs : List.of());
+
+ this.mode = builder.mode != null ? builder.mode : Mode.AUTO;
+ this.effectiveMode = resolveEffectiveMode(builder.mode);
+
+ // Keep the legacy getters literal — they return what the caller set,
+ // or the pre-v2 default if the caller never touched them. A
+ // "resolved view" that incorporated the new situation actions or
+ // mode detection would have been more accurate, but it would also
+ // flip the return value of {@code runAllIfNoMatches()} based on
+ // whether {@code CI} is set in the environment, which is exactly
+ // the kind of test-only determinism the pre-v2 API implicitly
+ // guaranteed. Engine code no longer consults these getters —
+ // see {@link #actionFor(Situation)}.
+ this.runAllIfNoMatches =
+ builder.runAllIfNoMatches != null ? builder.runAllIfNoMatches : false;
+ this.runAllOnNonJavaChange =
+ builder.runAllOnNonJavaChange != null ? builder.runAllOnNonJavaChange : true;
+
+ this.situationActions = resolveSituationActions(builder, this.effectiveMode);
+ }
+
+ /**
+ * Resolves the effective mode for action defaults. When the caller did
+ * not set a mode at all (null), we deliberately resolve to {@code null}
+ * here — the resolver downstream treats that as "fall through to the
+ * pre-v2 legacy-boolean defaults" instead of consulting a mode table.
+ * That keeps zero-config callers on the exact pre-v2 behaviour even
+ * when {@code $CI} happens to be set, which is what prevents the
+ * existing engine tests from going flaky on GitHub Actions runners.
+ */
+ private static Mode resolveEffectiveMode(Mode configured) {
+ if (configured == null) return null;
+ if (configured == Mode.AUTO) return Builder.detectMode();
+ return configured;
+ }
+
+ /**
+ * Per-situation actions in strict priority order:
+ *
+ *
the caller's explicit {@code on*} action,
+ *
the translation of whichever legacy boolean that situation
+ * was historically driven by,
+ *
the per-mode default (only when the caller set an explicit
+ * mode — {@code AUTO}/unset falls through to the final branch),
+ *
the hard-coded pre-v2 default, kept identical to the legacy
+ * boolean defaults so zero-config callers continue to observe
+ * pre-v2 behaviour exactly.
+ *
+ */
+ private static Map resolveSituationActions(Builder b, Mode effectiveMode) {
+ Action legacyNoMatches = (b.runAllIfNoMatches == null)
+ ? null
+ : (b.runAllIfNoMatches ? Action.FULL_SUITE : Action.SKIPPED);
+ // runAllOnNonJavaChange=false historically meant "ignore the unmapped
+ // file and proceed with whatever Java the diff touched" — that is
+ // {@code SELECTED} in the v2 model, not {@code SKIPPED}. The latter
+ // would have regressed every pre-v2 caller that opted out of the
+ // safety net expecting discovery to still run.
+ Action legacyNonJava = (b.runAllOnNonJavaChange == null)
+ ? null
+ : (b.runAllOnNonJavaChange ? Action.FULL_SUITE : Action.SELECTED);
+
+ EnumMap 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);
+ }
+
+ 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;
+ }
+
+ /**
+ * Per-mode defaults (see {@link Mode} javadoc for the full table).
+ * Only invoked when the caller set an explicit mode — {@code AUTO}
+ * without any legacy override falls through to the pre-v2 defaults
+ * instead.
+ */
+ private static Action defaultFor(Situation s, Mode effectiveMode) {
+ return switch (effectiveMode) {
+ case LOCAL -> switch (s) {
+ case EMPTY_DIFF, ALL_FILES_IGNORED, ALL_FILES_OUT_OF_SCOPE, DISCOVERY_EMPTY -> Action.SKIPPED;
+ case UNMAPPED_FILE -> Action.FULL_SUITE;
+ case DISCOVERY_SUCCESS -> Action.SELECTED;
+ };
+ case CI -> switch (s) {
+ case EMPTY_DIFF, ALL_FILES_IGNORED, ALL_FILES_OUT_OF_SCOPE -> Action.SKIPPED;
+ case UNMAPPED_FILE, DISCOVERY_EMPTY -> Action.FULL_SUITE;
+ case DISCOVERY_SUCCESS -> Action.SELECTED;
+ };
+ case STRICT -> switch (s) {
+ case ALL_FILES_OUT_OF_SCOPE -> Action.SKIPPED;
+ case EMPTY_DIFF, ALL_FILES_IGNORED, UNMAPPED_FILE, DISCOVERY_EMPTY -> Action.FULL_SUITE;
+ case DISCOVERY_SUCCESS -> Action.SELECTED;
+ };
+ case AUTO -> throw new IllegalStateException(
+ "AUTO must be resolved to LOCAL or CI before calling defaultFor");
+ };
}
public String baseRef() { return baseRef; }
@@ -56,7 +225,7 @@ private AffectedTestsConfig(Builder builder) {
* file that cannot be resolved to a Java class under the configured
* {@link #sourceDirs()} or {@link #testDirs()} — for example
* {@code application.yml}, {@code build.gradle}, a Liquibase changelog,
- * or a logback config. Files matching {@link #excludePaths()} are
+ * or a logback config. Files matching {@link #ignorePaths()} are
* treated as an explicit opt-out and do not trigger the escalation.
*
*
Default: {@code true} — "run more, never run less".
@@ -69,29 +238,155 @@ private AffectedTestsConfig(Builder builder) {
public List testSuffixes() { return testSuffixes; }
public List sourceDirs() { return sourceDirs; }
public List testDirs() { return testDirs; }
- public List excludePaths() { return excludePaths; }
+
+ /**
+ * Glob patterns for files that must not influence test selection at all.
+ * A diff consisting entirely of ignored paths routes through
+ * {@link Situation#ALL_FILES_IGNORED}.
+ *
+ * @return the ignore paths list
+ */
+ public List ignorePaths() { return ignorePaths; }
+
+ /**
+ * Back-compat alias for {@link #ignorePaths()}. Returns the same list —
+ * v2 collapsed the two legacy names into a single effective list so
+ * downstream code never has to consult both.
+ *
+ * @return the effective ignore paths list (identical to {@link #ignorePaths()})
+ * @deprecated use {@link #ignorePaths()} in new code; both return the same value.
+ */
+ @Deprecated
+ public List excludePaths() { return ignorePaths; }
+
+ /**
+ * Test source directories (e.g. {@code api-test/src/test/java}) that the
+ * plugin must not resolve as in-scope tests. A diff consisting entirely
+ * of files under these directories routes through
+ * {@link Situation#ALL_FILES_OUT_OF_SCOPE}. Intended for test source sets
+ * the user does not want the {@code affectedTest} task to dispatch
+ * (Cucumber/api-test, performance tests, etc.).
+ *
+ * @return the out-of-scope test directories
+ */
+ public List outOfScopeTestDirs() { return outOfScopeTestDirs; }
+
+ /**
+ * Production source directories the plugin must not consider as in-scope
+ * sources during mapping and discovery. A diff entirely under these
+ * directories also routes through {@link Situation#ALL_FILES_OUT_OF_SCOPE}.
+ *
+ * @return the out-of-scope source directories
+ */
+ public List outOfScopeSourceDirs() { return outOfScopeSourceDirs; }
public boolean includeImplementationTests() { return includeImplementationTests; }
public List implementationNaming() { return implementationNaming; }
+ /**
+ * The configured {@link Mode} — the raw value as set by the caller.
+ * May be {@link Mode#AUTO}. Use {@link #effectiveMode()} to read the
+ * already-resolved mode.
+ *
+ * @return the configured mode (may be AUTO)
+ */
+ public Mode mode() { return mode; }
+
+ /**
+ * The mode after {@link Mode#AUTO} resolution. Always one of
+ * {@link Mode#LOCAL}, {@link Mode#CI} or {@link Mode#STRICT}.
+ *
+ * @return the resolved mode
+ */
+ public Mode effectiveMode() { return effectiveMode; }
+
+ /**
+ * The {@link Action} the engine will take for a given {@link Situation}.
+ * Produced by layering the explicit caller-set action (highest priority),
+ * then the legacy-boolean translation, then the mode default.
+ *
+ * @param situation the situation to resolve
+ * @return the configured action for {@code situation}
+ */
+ public Action actionFor(Situation situation) {
+ return Objects.requireNonNull(situationActions.get(situation), "no action for " + situation);
+ }
+
+ /**
+ * View of the full per-situation action map. Useful for diagnostic
+ * output like {@code --explain}; engine code should prefer
+ * {@link #actionFor(Situation)}.
+ *
+ * @return an immutable situation-to-action map
+ */
+ public Map situationActions() { return situationActions; }
+
/** Creates a builder with sensible defaults. */
public static Builder builder() {
return new Builder();
}
public static final class Builder {
+
+ /**
+ * Default list of paths that must never influence test selection —
+ * wider than the pre-v2 default ({@code ["**}{@code /generated/**"]})
+ * so markdown-only PRs don't sneak past ignore rules on zero-config
+ * installs.
+ */
+ static final List DEFAULT_IGNORE_PATHS = List.of(
+ // Each "extension" category is listed twice: once for the
+ // root-level form ({@code *.md}) and once for the nested
+ // form ({@code **}{@code /*.md}). Java's glob PathMatcher
+ // does NOT treat {@code **}{@code /} as optional — the
+ // root-level forms are genuinely required or a pure
+ // "README.md" diff silently falls through to the unmapped
+ // bucket and triggers the full-suite safety net on
+ // zero-config installs.
+ "**/generated/**",
+ "*.md", "**/*.md",
+ "*.txt", "**/*.txt",
+ "LICENSE", "**/LICENSE",
+ "LICENSE.*", "**/LICENSE.*",
+ "CHANGELOG*", "**/CHANGELOG*",
+ "*.png", "**/*.png",
+ "*.jpg", "**/*.jpg",
+ "*.jpeg", "**/*.jpeg",
+ "*.svg", "**/*.svg",
+ "*.gif", "**/*.gif"
+ );
+
private String baseRef = "origin/master";
private boolean includeUncommitted = true;
private boolean includeStaged = true;
- private boolean runAllIfNoMatches = false;
- private boolean runAllOnNonJavaChange = true;
+ private Boolean runAllIfNoMatches;
+ private Boolean runAllOnNonJavaChange;
private Set strategies = Set.of(STRATEGY_NAMING, STRATEGY_USAGE, STRATEGY_IMPL, STRATEGY_TRANSITIVE);
- private int transitiveDepth = 2;
+ // 4 matches the v2 design: most real-world ctrl -> svc -> repo ->
+ // mapper chains are 2-3 deep, so 4 leaves headroom without
+ // exploding discovery cost. Callers can still clamp back to 2
+ // with the {@link #transitiveDepth(int)} setter.
+ private int transitiveDepth = 4;
private List testSuffixes = List.of("Test", "IT", "ITTest", "IntegrationTest");
private List sourceDirs = List.of("src/main/java");
private List testDirs = List.of("src/test/java");
- private List excludePaths = List.of("**/generated/**");
+ private List ignorePaths;
+ private List excludePaths;
+ private List outOfScopeTestDirs;
+ private List outOfScopeSourceDirs;
private boolean includeImplementationTests = true;
- private List implementationNaming = List.of("Impl");
+ // "Default" covers the Java-idiom pattern of {@code FooService} with
+ // a {@code DefaultFooService} implementation; "Impl" covers the
+ // {@code FooServiceImpl} pattern. v1 only knew about "Impl", which
+ // silently dropped tests for every "Default"-prefixed impl in the
+ // wild on zero-config installs.
+ private List implementationNaming = List.of("Impl", "Default");
+
+ private Mode mode;
+ private Action onEmptyDiff;
+ private Action onAllFilesIgnored;
+ private Action onAllFilesOutOfScope;
+ private Action onUnmappedFile;
+ private Action onDiscoveryEmpty;
public Builder baseRef(String baseRef) {
if (baseRef == null || baseRef.isBlank()) {
@@ -129,18 +424,96 @@ public Builder testDirs(List v) {
this.testDirs = Objects.requireNonNull(v, "testDirs must not be null");
return this;
}
+
+ /**
+ * Back-compat alias for {@link #ignorePaths(List)}. If both are set,
+ * {@link #ignorePaths(List)} wins.
+ *
+ * @param v the exclude paths
+ * @return this builder
+ * @deprecated prefer {@link #ignorePaths(List)} for new code.
+ */
+ @Deprecated
public Builder excludePaths(List v) {
this.excludePaths = Objects.requireNonNull(v, "excludePaths must not be null");
return this;
}
+
+ /** Glob patterns for files the plugin must ignore entirely. */
+ public Builder ignorePaths(List v) {
+ this.ignorePaths = Objects.requireNonNull(v, "ignorePaths must not be null");
+ return this;
+ }
+
+ /** Test source directories the plugin must not dispatch (e.g. {@code api-test/src/test/java}). */
+ public Builder outOfScopeTestDirs(List v) {
+ this.outOfScopeTestDirs = Objects.requireNonNull(v, "outOfScopeTestDirs must not be null");
+ return this;
+ }
+
+ /** Production source directories the plugin must treat as out-of-scope. */
+ public Builder outOfScopeSourceDirs(List v) {
+ this.outOfScopeSourceDirs = Objects.requireNonNull(v, "outOfScopeSourceDirs must not be null");
+ return this;
+ }
+
public Builder includeImplementationTests(boolean v) { this.includeImplementationTests = v; return this; }
public Builder implementationNaming(List v) {
this.implementationNaming = Objects.requireNonNull(v, "implementationNaming must not be null");
return this;
}
+ public Builder mode(Mode v) {
+ this.mode = Objects.requireNonNull(v, "mode must not be null");
+ return this;
+ }
+ public Builder onEmptyDiff(Action v) {
+ this.onEmptyDiff = Objects.requireNonNull(v, "onEmptyDiff must not be null");
+ return this;
+ }
+ public Builder onAllFilesIgnored(Action v) {
+ this.onAllFilesIgnored = Objects.requireNonNull(v, "onAllFilesIgnored must not be null");
+ return this;
+ }
+ public Builder onAllFilesOutOfScope(Action v) {
+ this.onAllFilesOutOfScope = Objects.requireNonNull(v, "onAllFilesOutOfScope must not be null");
+ return this;
+ }
+ public Builder onUnmappedFile(Action v) {
+ this.onUnmappedFile = Objects.requireNonNull(v, "onUnmappedFile must not be null");
+ return this;
+ }
+ public Builder onDiscoveryEmpty(Action v) {
+ this.onDiscoveryEmpty = Objects.requireNonNull(v, "onDiscoveryEmpty must not be null");
+ return this;
+ }
+
public AffectedTestsConfig build() {
return new AffectedTestsConfig(this);
}
+
+ /**
+ * Detects whether the current process is running in CI via common
+ * env vars. Kept package-private so tests can verify the detection
+ * rules without going through {@link #build()}.
+ */
+ static Mode detectMode() {
+ if (envSet("CI")
+ || envSet("GITLAB_CI")
+ || envSet("GITHUB_ACTIONS")
+ || envSet("JENKINS_HOME")
+ || envSet("CIRCLECI")
+ || envSet("TRAVIS")
+ || envSet("BUILDKITE")
+ || envSet("TF_BUILD")) {
+ return Mode.CI;
+ }
+ return Mode.LOCAL;
+ }
+
+ private static boolean envSet(String name) {
+ String v = System.getenv(name);
+ return v != null && !v.isBlank();
+ }
}
}
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/Mode.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/Mode.java
new file mode 100644
index 0000000..cef9123
--- /dev/null
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/Mode.java
@@ -0,0 +1,41 @@
+package io.affectedtests.core.config;
+
+/**
+ * Execution profile that seeds the per-situation {@link Action} defaults.
+ *
+ *
{@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.
+ *
+ *
Defaults per mode (used only when the caller has set neither the
+ * specific situation action nor the legacy boolean that would translate to
+ * it):
+ *
+ *
+ *
Per-mode action defaults
+ *
EMPTY_DIFF
ALL_IGNORED
ALL_OUT_OF_SCOPE
UNMAPPED_FILE
DISCOVERY_EMPTY
+ *
LOCAL
SKIPPED
SKIPPED
SKIPPED
FULL_SUITE
SKIPPED
+ *
CI
SKIPPED
SKIPPED
SKIPPED
FULL_SUITE
FULL_SUITE
+ *
STRICT
FULL_SUITE
FULL_SUITE
SKIPPED
FULL_SUITE
FULL_SUITE
+ *
+ *
+ *
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
+}
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/config/Situation.java b/affected-tests-core/src/main/java/io/affectedtests/core/config/Situation.java
new file mode 100644
index 0000000..322d904
--- /dev/null
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/config/Situation.java
@@ -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}.
+ *
+ *
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.
+ *
+ *
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
+}
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/discovery/ProjectIndex.java b/affected-tests-core/src/main/java/io/affectedtests/core/discovery/ProjectIndex.java
index 0578185..ab24619 100644
--- a/affected-tests-core/src/main/java/io/affectedtests/core/discovery/ProjectIndex.java
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/discovery/ProjectIndex.java
@@ -43,20 +43,40 @@ private ProjectIndex(List sourceFiles, List testFiles,
public static ProjectIndex build(Path projectDir, AffectedTestsConfig config) {
log.info("Building project index for {}", projectDir);
- List sourceFiles = SourceFileScanner.collectSourceFiles(projectDir, config.sourceDirs());
- List testFiles = SourceFileScanner.collectTestFiles(projectDir, config.testDirs());
+ List oosSource = config.outOfScopeSourceDirs();
+ List oosTest = config.outOfScopeTestDirs();
+
+ List sourceFiles = filterOutOfScope(
+ SourceFileScanner.collectSourceFiles(projectDir, config.sourceDirs()),
+ projectDir, oosSource, oosTest);
+ List testFiles = filterOutOfScope(
+ SourceFileScanner.collectTestFiles(projectDir, config.testDirs()),
+ projectDir, oosSource, oosTest);
+
LinkedHashMap 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 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),
@@ -66,6 +86,50 @@ public static ProjectIndex build(Path projectDir, AffectedTestsConfig config) {
);
}
+ private static List filterOutOfScope(List files, Path projectDir,
+ List oosSource, List oosTest) {
+ if (oosSource.isEmpty() && oosTest.isEmpty()) {
+ return files;
+ }
+ List 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 oosSource, List 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 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 sourceFiles() { return sourceFiles; }
public List testFiles() { return testFiles; }
public Set testFqns() { return testFqnToPath.keySet(); }
diff --git a/affected-tests-core/src/main/java/io/affectedtests/core/mapping/PathToClassMapper.java b/affected-tests-core/src/main/java/io/affectedtests/core/mapping/PathToClassMapper.java
index a7f7a10..eff3faa 100644
--- a/affected-tests-core/src/main/java/io/affectedtests/core/mapping/PathToClassMapper.java
+++ b/affected-tests-core/src/main/java/io/affectedtests/core/mapping/PathToClassMapper.java
@@ -12,37 +12,54 @@
/**
* Maps file paths (from git diff output) to fully-qualified Java class names.
- * Separates production sources from test sources and filters by exclusion patterns.
+ * Separates production sources from test sources, tags files as ignored /
+ * out-of-scope where the config says so, and surfaces everything else as
+ * unmapped.
+ *
+ *
The five mutually-exclusive buckets of {@link MappingResult} are what
+ * {@link io.affectedtests.core.AffectedTestsEngine} uses to pick a
+ * {@link io.affectedtests.core.config.Situation}. The mapper MUST NOT drop
+ * a changed file into silence: every input path appears in exactly one
+ * bucket, so the engine can always answer "why did we route to X?" with
+ * one bucket count.
*/
public final class PathToClassMapper {
private static final Logger log = LoggerFactory.getLogger(PathToClassMapper.class);
private final AffectedTestsConfig config;
- private final List excludeMatchers;
+ private final List ignoreMatchers;
public PathToClassMapper(AffectedTestsConfig config) {
this.config = config;
- this.excludeMatchers = config.excludePaths().stream()
+ this.ignoreMatchers = config.ignorePaths().stream()
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
.toList();
}
/**
- * Result of mapping changed files: production classes and test classes.
+ * Result of mapping changed files, split into five mutually-exclusive
+ * buckets so the engine can map any diff to exactly one
+ * {@link io.affectedtests.core.config.Situation}.
*
- *
{@link #unmappedChangedFiles()} captures paths that a Java-source
- * walk cannot interpret — typically non-Java files (YAML, build scripts,
- * Liquibase migrations) but also any {@code .java} file outside the
- * configured source/test directories. Files matched by
- * {@link AffectedTestsConfig#excludePaths()} are deliberately omitted so
- * that explicit opt-outs stay silent.
+ *
{@link #ignoredFiles()} captures paths matched by
+ * {@link AffectedTestsConfig#ignorePaths()}. {@link #outOfScopeFiles()}
+ * captures paths that sit under
+ * {@link AffectedTestsConfig#outOfScopeTestDirs()} or
+ * {@link AffectedTestsConfig#outOfScopeSourceDirs()}.
+ * {@link #unmappedChangedFiles()} is the "fallthrough" bucket — any
+ * file that wasn't ignored, out-of-scope, a production Java source or
+ * a test Java source ends up here (YAML, build scripts, Liquibase
+ * migrations, stray {@code .java} files outside the configured source
+ * trees).
*/
public record MappingResult(
Set productionClasses,
Set testClasses,
Set changedProductionFiles,
Set changedTestFiles,
+ Set ignoredFiles,
+ Set outOfScopeFiles,
Set unmappedChangedFiles
) {}
@@ -57,12 +74,30 @@ public MappingResult mapChangedFiles(Set changedFiles) {
Set testClasses = new LinkedHashSet<>();
Set changedProductionFiles = new LinkedHashSet<>();
Set changedTestFiles = new LinkedHashSet<>();
+ Set ignoredFiles = new LinkedHashSet<>();
+ Set outOfScopeFiles = new LinkedHashSet<>();
Set unmappedChangedFiles = new LinkedHashSet<>();
for (String filePath : changedFiles) {
- if (isExcluded(filePath)) {
- // Explicit opt-out — stay silent and do not treat as unmapped.
- log.debug("Excluded by pattern: {}", filePath);
+ // Ignore rules are evaluated FIRST: a user's explicit
+ // {@code ignorePaths} entry is a contract that nothing about
+ // the file should influence the engine, including nudging it
+ // into the out-of-scope bucket.
+ if (isIgnored(filePath)) {
+ ignoredFiles.add(filePath);
+ log.debug("Ignored by pattern: {}", filePath);
+ continue;
+ }
+
+ // Out-of-scope dirs are evaluated BEFORE the Java mapper so a
+ // {@code .java} file under {@code api-test/src/test/java} is
+ // 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())) {
+ outOfScopeFiles.add(filePath);
+ log.debug("Out-of-scope: {}", filePath);
continue;
}
@@ -72,7 +107,6 @@ public MappingResult mapChangedFiles(Set changedFiles) {
continue;
}
- // Check if it's under a test source dir
String testFqn = tryMapToClass(filePath, config.testDirs());
if (testFqn != null) {
testClasses.add(testFqn);
@@ -81,7 +115,6 @@ public MappingResult mapChangedFiles(Set changedFiles) {
continue;
}
- // Check if it's under a production source dir
String prodFqn = tryMapToClass(filePath, config.sourceDirs());
if (prodFqn != null) {
productionClasses.add(prodFqn);
@@ -96,11 +129,15 @@ public MappingResult mapChangedFiles(Set changedFiles) {
unmappedChangedFiles.add(filePath);
}
- log.info("Mapped {} production classes and {} test classes from {} changed files ({} unmapped)",
- productionClasses.size(), testClasses.size(), changedFiles.size(), unmappedChangedFiles.size());
+ log.info("Mapped {} production, {} test, {} ignored, {} out-of-scope, {} unmapped "
+ + "(total {} changed files)",
+ productionClasses.size(), testClasses.size(),
+ ignoredFiles.size(), outOfScopeFiles.size(), unmappedChangedFiles.size(),
+ changedFiles.size());
return new MappingResult(productionClasses, testClasses,
- changedProductionFiles, changedTestFiles, unmappedChangedFiles);
+ changedProductionFiles, changedTestFiles,
+ ignoredFiles, outOfScopeFiles, unmappedChangedFiles);
}
/**
@@ -179,13 +216,32 @@ private String extractModuleFromDirs(String normalizedPath, java.util.List dirs) {
+ if (dirs.isEmpty()) return false;
+ String normalized = filePath.replace('\\', '/');
+ 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;
+ }
+ return false;
+ }
}
diff --git a/affected-tests-core/src/test/java/io/affectedtests/core/AffectedTestsEngineTest.java b/affected-tests-core/src/test/java/io/affectedtests/core/AffectedTestsEngineTest.java
index 8237bcd..5320444 100644
--- a/affected-tests-core/src/test/java/io/affectedtests/core/AffectedTestsEngineTest.java
+++ b/affected-tests-core/src/test/java/io/affectedtests/core/AffectedTestsEngineTest.java
@@ -1,7 +1,10 @@
package io.affectedtests.core;
import io.affectedtests.core.AffectedTestsEngine.EscalationReason;
+import io.affectedtests.core.config.Action;
import io.affectedtests.core.config.AffectedTestsConfig;
+import io.affectedtests.core.config.Mode;
+import io.affectedtests.core.config.Situation;
import org.eclipse.jgit.api.Git;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -380,6 +383,103 @@ void runAllOnNonJavaChangeCanBeDisabled() throws Exception {
}
}
+ @Test
+ void apiTestOnlyDiffRoutesToOutOfScopeAndSkips() throws Exception {
+ // Canonical v2 scenario: a Cucumber/api-test-only diff must
+ // short-circuit to SKIPPED without dragging the unit-test
+ // dispatcher into a full-suite run.
+ try (Git git = initRepoWithInitialCommit()) {
+ String base = git.log().call().iterator().next().getName();
+
+ Path apiTestDir = tempDir.resolve("api-test/src/test/java/com/example/api");
+ Files.createDirectories(apiTestDir);
+ Files.writeString(apiTestDir.resolve("FooSteps.java"),
+ "package com.example.api;\npublic class FooSteps {}");
+
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("api-test only").call();
+
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .baseRef(base)
+ .includeUncommitted(false)
+ .includeStaged(false)
+ .outOfScopeTestDirs(java.util.List.of("api-test/src/test/java"))
+ .build();
+
+ AffectedTestsEngine engine = new AffectedTestsEngine(config, tempDir);
+ AffectedTestsEngine.AffectedTestsResult result = engine.run();
+
+ assertEquals(Situation.ALL_FILES_OUT_OF_SCOPE, result.situation());
+ assertEquals(Action.SKIPPED, result.action());
+ assertTrue(result.skipped());
+ assertFalse(result.runAll());
+ assertTrue(result.testClassFqns().isEmpty());
+ assertEquals(EscalationReason.NONE, result.escalationReason());
+ }
+ }
+
+ @Test
+ void markdownOnlyDiffRoutesToAllFilesIgnoredAndSkips() throws Exception {
+ // Markdown is in the default ignore list — a docs-only diff must
+ // land on ALL_FILES_IGNORED and skip tests, not fall through to
+ // the unmapped-file safety net.
+ try (Git git = initRepoWithInitialCommit()) {
+ String base = git.log().call().iterator().next().getName();
+
+ Files.writeString(tempDir.resolve("docs.md"), "# docs");
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("docs only").call();
+
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .baseRef(base)
+ .includeUncommitted(false)
+ .includeStaged(false)
+ .build();
+
+ AffectedTestsEngine engine = new AffectedTestsEngine(config, tempDir);
+ AffectedTestsEngine.AffectedTestsResult result = engine.run();
+
+ assertEquals(Situation.ALL_FILES_IGNORED, result.situation());
+ assertEquals(Action.SKIPPED, result.action());
+ assertTrue(result.skipped());
+ assertFalse(result.runAll());
+ }
+ }
+
+ @Test
+ void modeCiEscalatesDiscoveryEmpty() throws Exception {
+ // Mode.CI's DISCOVERY_EMPTY default is FULL_SUITE — a CI user who
+ // opts into mode=CI without also setting the legacy boolean still
+ // gets the full-suite safety net on "found nothing" diffs.
+ try (Git git = initRepoWithInitialCommit()) {
+ String base = git.log().call().iterator().next().getName();
+
+ Path prodDir = tempDir.resolve("src/main/java/com/example");
+ Files.createDirectories(prodDir);
+ Files.writeString(prodDir.resolve("Orphan.java"),
+ "package com.example;\npublic class Orphan {}");
+ git.add().addFilepattern(".").call();
+ git.commit().setMessage("orphan").call();
+
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .baseRef(base)
+ .includeUncommitted(false)
+ .includeStaged(false)
+ .mode(Mode.CI)
+ .transitiveDepth(0)
+ .build();
+
+ AffectedTestsEngine engine = new AffectedTestsEngine(config, tempDir);
+ AffectedTestsEngine.AffectedTestsResult result = engine.run();
+
+ assertEquals(Situation.DISCOVERY_EMPTY, result.situation());
+ assertEquals(Action.FULL_SUITE, result.action());
+ assertTrue(result.runAll());
+ assertFalse(result.skipped());
+ assertEquals(EscalationReason.RUN_ALL_IF_NO_MATCHES, result.escalationReason());
+ }
+ }
+
@Test
void multiModuleTestPathsArePreservedInResult() throws Exception {
try (Git git = initRepoWithInitialCommit()) {
diff --git a/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java b/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java
index c267422..e0f1b25 100644
--- a/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java
+++ b/affected-tests-core/src/test/java/io/affectedtests/core/config/AffectedTestsConfigTest.java
@@ -16,15 +16,41 @@ void defaultsAreApplied() {
assertEquals("origin/master", config.baseRef());
assertTrue(config.includeUncommitted());
assertTrue(config.includeStaged());
+ // Pre-v2 legacy defaults preserved 1:1 for zero-config callers —
+ // the getters below read the raw configured value (or the
+ // hardcoded pre-v2 default when unset), not the resolved
+ // per-situation action, so the assertions stay deterministic
+ // regardless of whether the test runs in CI or on a laptop.
assertFalse(config.runAllIfNoMatches());
+ assertTrue(config.runAllOnNonJavaChange());
assertEquals(Set.of("naming", "usage", "impl", "transitive"), config.strategies());
- assertEquals(2, config.transitiveDepth());
+ // v2 raises the default from 2 to 4: real ctrl→svc→repo→mapper
+ // chains sit 3-4 deep so 2 dropped coverage on zero-config.
+ assertEquals(4, config.transitiveDepth());
assertEquals(List.of("Test", "IT", "ITTest", "IntegrationTest"), config.testSuffixes());
assertEquals(List.of("src/main/java"), config.sourceDirs());
assertEquals(List.of("src/test/java"), config.testDirs());
- assertEquals(List.of("**/generated/**"), config.excludePaths());
+ // v2 widens the default ignore list from "generated/**" only to a
+ // broader "things that can't possibly affect tests" baseline
+ // (markdown, images, licence/changelog). Assertion checks a
+ // representative subset; tests that care about exact contents
+ // should inspect {@link AffectedTestsConfig.Builder#DEFAULT_IGNORE_PATHS}.
+ assertTrue(config.ignorePaths().contains("**/generated/**"));
+ assertTrue(config.ignorePaths().contains("**/*.md"));
+ assertEquals(config.ignorePaths(), config.excludePaths(),
+ "excludePaths must alias ignorePaths in v2 — callers can't read two diverging lists");
assertTrue(config.includeImplementationTests());
- assertEquals(List.of("Impl"), config.implementationNaming());
+ assertEquals(List.of("Impl", "Default"), config.implementationNaming());
+ assertEquals(List.of(), config.outOfScopeTestDirs());
+ assertEquals(List.of(), config.outOfScopeSourceDirs());
+ assertEquals(Mode.AUTO, config.mode());
+ // Zero-config callers get pre-v2 situation actions from the
+ // hard-coded defaults — NOT from mode detection — so the result
+ // is deterministic whether or not $CI is set.
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.EMPTY_DIFF));
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.DISCOVERY_EMPTY));
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.UNMAPPED_FILE));
+ assertEquals(Action.SELECTED, config.actionFor(Situation.DISCOVERY_SUCCESS));
}
@Test
@@ -67,6 +93,85 @@ void transitiveDepthIsClamped() {
assertEquals(0, negative.transitiveDepth());
}
+ @Test
+ void explicitSituationActionWinsOverLegacyBooleans() {
+ // Explicit on* setter beats the legacy-boolean translation — a
+ // user migrating to v2 must be able to override a single branch
+ // without having to clear the legacy booleans first.
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .runAllIfNoMatches(true)
+ .onDiscoveryEmpty(Action.SKIPPED)
+ .build();
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.DISCOVERY_EMPTY));
+ // Sibling situations driven by the same legacy boolean still
+ // follow the legacy translation.
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.EMPTY_DIFF));
+ }
+
+ @Test
+ void legacyBooleanBeatsModeDefault() {
+ // Explicit legacy boolean beats the per-mode defaults so a CI
+ // user who set runAllIfNoMatches=false + mode=CI genuinely gets
+ // "skip" on empty diff instead of the CI safety-net full run.
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .runAllIfNoMatches(false)
+ .mode(Mode.CI)
+ .build();
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.DISCOVERY_EMPTY));
+ }
+
+ @Test
+ void modeCiEscalatesDiscoveryEmptyByDefault() {
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .mode(Mode.CI)
+ .build();
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.DISCOVERY_EMPTY));
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.EMPTY_DIFF));
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.ALL_FILES_OUT_OF_SCOPE));
+ assertEquals(Mode.CI, config.effectiveMode());
+ }
+
+ @Test
+ void modeStrictEscalatesEverythingAmbiguous() {
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .mode(Mode.STRICT)
+ .build();
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.EMPTY_DIFF));
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.ALL_FILES_IGNORED));
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.UNMAPPED_FILE));
+ assertEquals(Action.FULL_SUITE, config.actionFor(Situation.DISCOVERY_EMPTY));
+ // ...except out-of-scope, which is always SKIPPED even in STRICT:
+ // escalating it would fight the whole point of the knob.
+ assertEquals(Action.SKIPPED, config.actionFor(Situation.ALL_FILES_OUT_OF_SCOPE));
+ }
+
+ @Test
+ void runAllOnNonJavaChangeFalseTranslatesToSelected() {
+ // Pre-v2 callers that opted out of the safety net expected
+ // discovery to still run against whatever Java was in the diff.
+ // SKIPPED would regress that contract silently.
+ AffectedTestsConfig config = AffectedTestsConfig.builder()
+ .runAllOnNonJavaChange(false)
+ .build();
+ assertEquals(Action.SELECTED, config.actionFor(Situation.UNMAPPED_FILE));
+ }
+
+ @Test
+ void ignorePathsAliasesExcludePaths() {
+ AffectedTestsConfig byExclude = AffectedTestsConfig.builder()
+ .excludePaths(List.of("**/*.gen.java"))
+ .build();
+ assertEquals(List.of("**/*.gen.java"), byExclude.ignorePaths());
+ assertEquals(byExclude.ignorePaths(), byExclude.excludePaths());
+
+ // When both are set the new name wins.
+ AffectedTestsConfig both = AffectedTestsConfig.builder()
+ .excludePaths(List.of("old"))
+ .ignorePaths(List.of("new"))
+ .build();
+ assertEquals(List.of("new"), both.ignorePaths());
+ }
+
@Test
void baseRefRejectsBlank() {
assertThrows(IllegalArgumentException.class, () ->
diff --git a/affected-tests-core/src/test/java/io/affectedtests/core/mapping/PathToClassMapperTest.java b/affected-tests-core/src/test/java/io/affectedtests/core/mapping/PathToClassMapperTest.java
index 9f9ab2e..87e94ae 100644
--- a/affected-tests-core/src/test/java/io/affectedtests/core/mapping/PathToClassMapperTest.java
+++ b/affected-tests-core/src/test/java/io/affectedtests/core/mapping/PathToClassMapperTest.java
@@ -45,8 +45,12 @@ void mapsMultiModulePaths() {
@Test
void nonJavaFilesAreRecordedAsUnmapped() {
+ // README.md is deliberately omitted from this test: v2 added
+ // "**}{@code /*.md}" to the default ignore list, so a markdown
+ // file no longer contributes to the unmapped bucket. See
+ // {@link #markdownRoutesToIgnoredBucketOnDefaults} for the
+ // positive-path assertion on that.
Set changed = Set.of(
- "README.md",
"build.gradle",
"src/main/resources/application.yml"
);
@@ -58,6 +62,50 @@ void nonJavaFilesAreRecordedAsUnmapped() {
"Non-Java files must be surfaced as unmapped so the engine can escalate to runAll");
}
+ @Test
+ void markdownRoutesToIgnoredBucketOnDefaults() {
+ // v2 default ignorePaths contains "**}{@code /*.md}" so a pure
+ // markdown diff routes through Situation.ALL_FILES_IGNORED rather
+ // than dragging the engine into the unmapped-file safety net.
+ Set changed = Set.of("README.md", "docs/guide.md");
+ MappingResult result = mapper.mapChangedFiles(changed);
+
+ assertTrue(result.unmappedChangedFiles().isEmpty(),
+ "Markdown must not feed the unmapped bucket under v2 defaults");
+ assertEquals(changed, result.ignoredFiles(),
+ "Markdown must land in the ignored bucket so the engine can route to ALL_FILES_IGNORED");
+ }
+
+ @Test
+ void outOfScopeTestDirsFileRoutesToOutOfScopeBucket() {
+ // Regression for the api-test use case: a diff that only touches
+ // api-test sources must be surfaced as "out of scope" so the
+ // engine can skip the unit-test dispatch entirely, instead of
+ // trying to map the file as an in-scope test class. The test
+ // directory entries are the on-disk paths the plugin sees
+ // (no trailing slash, one per concrete sibling dir) so mixed
+ // diffs that touch {@code /java} and {@code /resources} both
+ // route to the same bucket.
+ AffectedTestsConfig oosConfig = AffectedTestsConfig.builder()
+ .outOfScopeTestDirs(java.util.List.of(
+ "api-test/src/test/java",
+ "api-test/src/test/resources"))
+ .build();
+ PathToClassMapper oosMapper = new PathToClassMapper(oosConfig);
+
+ Set changed = Set.of(
+ "api-test/src/test/java/com/example/api/FooSteps.java",
+ "api-test/src/test/resources/feature.feature");
+ MappingResult result = oosMapper.mapChangedFiles(changed);
+
+ assertTrue(result.testClasses().isEmpty(),
+ "Out-of-scope test file must not become an in-scope test class");
+ assertEquals(changed, result.outOfScopeFiles(),
+ "Out-of-scope diff must populate the out-of-scope bucket");
+ assertTrue(result.unmappedChangedFiles().isEmpty(),
+ "An out-of-scope file must not leak into the unmapped bucket");
+ }
+
@Test
void javaFileOutsideConfiguredDirsIsUnmapped() {
// A .java file that does not sit under any configured source or test
diff --git a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java
index 07615dc..7a13e17 100644
--- a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java
+++ b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestTask.java
@@ -3,7 +3,10 @@
import io.affectedtests.core.AffectedTestsEngine;
import io.affectedtests.core.AffectedTestsEngine.AffectedTestsResult;
import io.affectedtests.core.AffectedTestsEngine.EscalationReason;
+import io.affectedtests.core.config.Action;
import io.affectedtests.core.config.AffectedTestsConfig;
+import io.affectedtests.core.config.Mode;
+import io.affectedtests.core.config.Situation;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
@@ -81,24 +84,38 @@ public AffectedTestTask() {
/**
* Whether to run the full test suite when no affected tests are found.
- * Default: {@code false} (skip tests when nothing is affected).
+ * v2 back-compat — translated into {@link Situation#EMPTY_DIFF},
+ * {@link Situation#ALL_FILES_IGNORED},
+ * {@link Situation#ALL_FILES_OUT_OF_SCOPE}, and
+ * {@link Situation#DISCOVERY_EMPTY} actions by the core config
+ * builder when set. Unset means "let the v2 resolver pick defaults".
+ * Default: unset (matching pre-v2 {@code false} once translated).
+ *
+ *
Marked {@link org.gradle.api.tasks.Optional @Optional} because
+ * the extension no longer installs a convention — the Gradle task
+ * must be free to leave the property unset when the user has not
+ * overridden the legacy boolean.
*
* @return the run-all-if-no-matches property
*/
@Input
+ @org.gradle.api.tasks.Optional
public abstract Property getRunAllIfNoMatches();
/**
* Whether to force a full test run when the change set contains any
* file that cannot be resolved to a Java class under the configured
- * source/test directories (e.g. {@code application.yml},
- * {@code build.gradle}, a Liquibase changelog). Excluded paths are
- * honoured and do not trigger the escalation.
- * Default: {@code true} — "run more, never run less".
+ * source/test directories. v2 back-compat — translates into
+ * {@link Situation#UNMAPPED_FILE}'s action.
+ *
+ *
Marked {@link org.gradle.api.tasks.Optional @Optional} because
+ * the extension no longer installs a convention; leaving it unset
+ * is what lets the v2 resolver reach its own defaults.
*
* @return the run-all-on-non-java-change property
*/
@Input
+ @org.gradle.api.tasks.Optional
public abstract Property getRunAllOnNonJavaChange();
/**
@@ -149,14 +166,48 @@ public AffectedTestTask() {
public abstract ListProperty getTestDirs();
/**
- * Glob patterns for files to exclude from analysis.
- * Default: {@code ["**/generated/**"]}.
+ * v2 back-compat alias for {@link #getIgnorePaths()}. When neither
+ * is set, the core config's default ignore-path list applies.
*
* @return the exclude paths list property
+ * @deprecated prefer {@link #getIgnorePaths()}.
*/
@Input
+ @org.gradle.api.tasks.Optional
+ @Deprecated
public abstract ListProperty getExcludePaths();
+ /**
+ * Glob patterns for files that must never influence test selection.
+ * Optional — when unset, the core config's default list applies.
+ *
+ * @return the ignore paths list property
+ */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract ListProperty getIgnorePaths();
+
+ /**
+ * Test source directories (e.g. {@code "api-test/src/test/java"})
+ * whose contents the plugin must not dispatch via the
+ * {@code affectedTest} task.
+ *
+ * @return the out-of-scope test dirs list property
+ */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract ListProperty getOutOfScopeTestDirs();
+
+ /**
+ * Production source directories the plugin must treat as
+ * out-of-scope.
+ *
+ * @return the out-of-scope source dirs list property
+ */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract ListProperty getOutOfScopeSourceDirs();
+
/**
* Whether to include tests for implementations of changed interfaces/base classes.
* Default: {@code true}.
@@ -167,15 +218,50 @@ public AffectedTestTask() {
public abstract Property getIncludeImplementationTests();
/**
- * Suffixes for finding implementation classes (e.g. {@code "Impl"} matches
- * {@code FooServiceImpl} for a changed {@code FooService}).
- * Default: {@code ["Impl"]}.
+ * Suffixes/prefixes for finding implementation classes.
+ * Default: {@code ["Impl", "Default"]}.
*
* @return the implementation naming list property
*/
@Input
public abstract ListProperty getImplementationNaming();
+ /**
+ * Execution profile name — one of {@code "auto"}, {@code "local"},
+ * {@code "ci"}, {@code "strict"}. Unset leaves defaults in
+ * pre-v2 mode.
+ *
+ * @return the mode property
+ */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getMode();
+
+ /** @return the on-empty-diff situation action property */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getOnEmptyDiff();
+
+ /** @return the on-all-files-ignored situation action property */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getOnAllFilesIgnored();
+
+ /** @return the on-all-files-out-of-scope situation action property */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getOnAllFilesOutOfScope();
+
+ /** @return the on-unmapped-file situation action property */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getOnUnmappedFile();
+
+ /** @return the on-discovery-empty situation action property */
+ @Input
+ @org.gradle.api.tasks.Optional
+ public abstract Property getOnDiscoveryEmpty();
+
/**
* Map of subproject directory (relative to the root project, empty string
* for the root project itself) to the Gradle path of that subproject
@@ -210,21 +296,7 @@ public AffectedTestTask() {
public void runAffectedTests() {
Path projectDir = getRootDir().get().getAsFile().toPath();
- AffectedTestsConfig config = AffectedTestsConfig.builder()
- .baseRef(getBaseRef().get())
- .includeUncommitted(getIncludeUncommitted().get())
- .includeStaged(getIncludeStaged().get())
- .runAllIfNoMatches(getRunAllIfNoMatches().get())
- .runAllOnNonJavaChange(getRunAllOnNonJavaChange().get())
- .strategies(new LinkedHashSet<>(getStrategies().get()))
- .transitiveDepth(getTransitiveDepth().get())
- .testSuffixes(getTestSuffixes().get())
- .sourceDirs(getSourceDirs().get())
- .testDirs(getTestDirs().get())
- .excludePaths(getExcludePaths().get())
- .includeImplementationTests(getIncludeImplementationTests().get())
- .implementationNaming(getImplementationNaming().get())
- .build();
+ AffectedTestsConfig config = buildConfig();
AffectedTestsEngine engine = new AffectedTestsEngine(config, projectDir);
AffectedTestsResult result = engine.run();
@@ -239,6 +311,17 @@ public void runAffectedTests() {
LogLine summary = renderSummary(result);
getLogger().lifecycle(summary.format(), summary.args());
+ 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.");
return;
@@ -250,6 +333,81 @@ public void runAffectedTests() {
result.runAll());
}
+ /**
+ * Assembles the immutable core config from the task's Gradle
+ * properties. Legacy booleans and the new situation/mode knobs are
+ * all optional at this layer; the core builder handles precedence
+ * (explicit > legacy-boolean > mode > pre-v2 default).
+ */
+ private AffectedTestsConfig buildConfig() {
+ AffectedTestsConfig.Builder builder = AffectedTestsConfig.builder()
+ .baseRef(getBaseRef().get())
+ .includeUncommitted(getIncludeUncommitted().get())
+ .includeStaged(getIncludeStaged().get())
+ .strategies(new LinkedHashSet<>(getStrategies().get()))
+ .transitiveDepth(getTransitiveDepth().get())
+ .testSuffixes(getTestSuffixes().get())
+ .sourceDirs(getSourceDirs().get())
+ .testDirs(getTestDirs().get())
+ .includeImplementationTests(getIncludeImplementationTests().get())
+ .implementationNaming(getImplementationNaming().get());
+
+ if (getRunAllIfNoMatches().isPresent()) {
+ builder.runAllIfNoMatches(getRunAllIfNoMatches().get());
+ }
+ if (getRunAllOnNonJavaChange().isPresent()) {
+ builder.runAllOnNonJavaChange(getRunAllOnNonJavaChange().get());
+ }
+ if (getIgnorePaths().isPresent() && !getIgnorePaths().get().isEmpty()) {
+ builder.ignorePaths(getIgnorePaths().get());
+ } else if (getExcludePaths().isPresent() && !getExcludePaths().get().isEmpty()) {
+ builder.excludePaths(getExcludePaths().get());
+ }
+ if (getOutOfScopeTestDirs().isPresent() && !getOutOfScopeTestDirs().get().isEmpty()) {
+ builder.outOfScopeTestDirs(getOutOfScopeTestDirs().get());
+ }
+ if (getOutOfScopeSourceDirs().isPresent() && !getOutOfScopeSourceDirs().get().isEmpty()) {
+ builder.outOfScopeSourceDirs(getOutOfScopeSourceDirs().get());
+ }
+ if (getMode().isPresent()) {
+ builder.mode(parseMode(getMode().get()));
+ }
+ if (getOnEmptyDiff().isPresent()) {
+ builder.onEmptyDiff(parseAction(getOnEmptyDiff().get(), "onEmptyDiff"));
+ }
+ if (getOnAllFilesIgnored().isPresent()) {
+ builder.onAllFilesIgnored(parseAction(getOnAllFilesIgnored().get(), "onAllFilesIgnored"));
+ }
+ if (getOnAllFilesOutOfScope().isPresent()) {
+ builder.onAllFilesOutOfScope(parseAction(getOnAllFilesOutOfScope().get(), "onAllFilesOutOfScope"));
+ }
+ if (getOnUnmappedFile().isPresent()) {
+ builder.onUnmappedFile(parseAction(getOnUnmappedFile().get(), "onUnmappedFile"));
+ }
+ if (getOnDiscoveryEmpty().isPresent()) {
+ builder.onDiscoveryEmpty(parseAction(getOnDiscoveryEmpty().get(), "onDiscoveryEmpty"));
+ }
+ return builder.build();
+ }
+
+ private static Mode parseMode(String raw) {
+ try {
+ return Mode.valueOf(raw.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new GradleException("Unknown affectedTests.mode '" + raw
+ + "'. Expected one of: auto, local, ci, strict.", e);
+ }
+ }
+
+ private static Action parseAction(String raw, String property) {
+ try {
+ return Action.valueOf(raw.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new GradleException("Unknown " + property + " action '" + raw
+ + "'. Expected one of: selected, full_suite, skipped.", e);
+ }
+ }
+
private void executeTests(Path projectDir,
Set testFqns,
Map fqnToPath,
@@ -433,13 +591,21 @@ public Object[] args() {
*/
static String describeEscalation(EscalationReason reason) {
Objects.requireNonNull(reason, "reason");
+ // Phrases deliberately keep the legacy flag names ("runAllIfNoMatches=true",
+ // "runAllOnNonJavaChange=true") alongside the new situation-based name so
+ // existing CI greps stay matched. Removing either side would require a
+ // coordinated pipeline migration we do not want to force in Phase 1.
return switch (reason) {
case RUN_ALL_ON_NON_JAVA_CHANGE ->
- "runAllOnNonJavaChange=true — non-Java or unmapped file in diff";
+ "runAllOnNonJavaChange=true / onUnmappedFile=FULL_SUITE — non-Java or unmapped file in diff";
case RUN_ALL_ON_EMPTY_CHANGESET ->
- "runAllIfNoMatches=true — no changed files detected";
+ "runAllIfNoMatches=true / onEmptyDiff=FULL_SUITE — no changed files detected";
case RUN_ALL_IF_NO_MATCHES ->
- "runAllIfNoMatches=true — no affected tests discovered";
+ "runAllIfNoMatches=true / onDiscoveryEmpty=FULL_SUITE — no affected tests discovered";
+ case RUN_ALL_ON_ALL_FILES_IGNORED ->
+ "onAllFilesIgnored=FULL_SUITE — every changed file matched ignorePaths";
+ case RUN_ALL_ON_ALL_FILES_OUT_OF_SCOPE ->
+ "onAllFilesOutOfScope=FULL_SUITE — every changed file sat under out-of-scope dirs";
case NONE -> throw new IllegalStateException(
"describeEscalation must not be called for EscalationReason.NONE; "
+ "the engine should only produce NONE on non-runAll results");
diff --git a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsExtension.java b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsExtension.java
index 92a514f..7ce9fe0 100644
--- a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsExtension.java
+++ b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsExtension.java
@@ -118,12 +118,51 @@ public abstract class AffectedTestsExtension {
/**
* Glob patterns for files to exclude from analysis.
- * Default: {@code ["**/generated/**"]}.
+ * v2 back-compat alias for {@link #getIgnorePaths()}. Default is an
+ * empty Gradle property — when unset, the v2 {@code ignorePaths}
+ * default applies (a broader list than the pre-v2 single-entry
+ * {@code ["**}{@code /generated/**"]} list).
*
* @return the exclude paths list property
+ * @deprecated prefer {@link #getIgnorePaths()} in new configs.
*/
+ @Deprecated
public abstract ListProperty getExcludePaths();
+ /**
+ * Glob patterns for files that must never influence test selection —
+ * for purely documentation, build metadata, or generated artifacts.
+ * A diff consisting entirely of ignored paths routes through
+ * {@code Situation.ALL_FILES_IGNORED}.
+ *
+ *
When unset, the core {@code AffectedTestsConfig} default list
+ * applies (markdown, generated/, text/licence/changelog, images).
+ *
+ * @return the ignore paths list property
+ */
+ public abstract ListProperty getIgnorePaths();
+
+ /**
+ * Test source directories (e.g. {@code "api-test/src/test/java"})
+ * whose contents the plugin must not dispatch via the
+ * {@code affectedTest} task. A diff entirely under these directories
+ * routes through {@code Situation.ALL_FILES_OUT_OF_SCOPE}. Intended
+ * for Cucumber/api-test, performance, or other non-unit-test source
+ * sets.
+ *
+ * @return the out-of-scope test dirs list property
+ */
+ public abstract ListProperty getOutOfScopeTestDirs();
+
+ /**
+ * Production source directories the plugin must treat as
+ * out-of-scope. A diff entirely under these dirs routes through
+ * {@code Situation.ALL_FILES_OUT_OF_SCOPE}.
+ *
+ * @return the out-of-scope source dirs list property
+ */
+ public abstract ListProperty getOutOfScopeSourceDirs();
+
/**
* Include tests for implementations of changed interfaces/base classes.
* Default: {@code true}.
@@ -133,10 +172,73 @@ public abstract class AffectedTestsExtension {
public abstract Property getIncludeImplementationTests();
/**
- * Implementation naming suffixes (e.g. "Impl" matches FooBarImpl for FooBar).
- * Default: {@code ["Impl"]}.
+ * Implementation naming prefixes/suffixes (e.g. "Impl" matches
+ * {@code FooBarImpl} for {@code FooBar}; "Default" matches
+ * {@code DefaultFooBar} for {@code FooBar}).
+ * Default: {@code ["Impl", "Default"]}.
*
* @return the implementation naming list property
*/
public abstract ListProperty getImplementationNaming();
+
+ /**
+ * Execution profile ({@code "auto"}, {@code "local"}, {@code "ci"},
+ * or {@code "strict"}). Controls per-situation default actions
+ * when neither the explicit {@code onXxx} setting nor the legacy
+ * boolean translation has picked one.
+ *
+ *
Default: unset — which preserves the pre-v2 defaults for
+ * zero-config users. Setting it to {@code "auto"} is the
+ * recommended migration: it detects CI via common env vars and
+ * picks the CI defaults there, and the LOCAL defaults otherwise.
+ *
+ * @return the mode property
+ */
+ public abstract Property getMode();
+
+ /**
+ * Action to take on an empty git diff. One of {@code "selected"},
+ * {@code "full_suite"}, {@code "skipped"} (case-insensitive).
+ *
+ * @return the on-empty-diff property
+ */
+ public abstract Property getOnEmptyDiff();
+
+ /**
+ * Action to take when every file in the diff matched
+ * {@link #getIgnorePaths()}. One of {@code "selected"},
+ * {@code "full_suite"}, {@code "skipped"}.
+ *
+ * @return the on-all-files-ignored property
+ */
+ public abstract Property getOnAllFilesIgnored();
+
+ /**
+ * Action to take when every file in the diff sat under
+ * {@link #getOutOfScopeTestDirs()} or {@link #getOutOfScopeSourceDirs()}.
+ * One of {@code "selected"}, {@code "full_suite"}, {@code "skipped"}.
+ *
+ * @return the on-all-files-out-of-scope property
+ */
+ public abstract Property getOnAllFilesOutOfScope();
+
+ /**
+ * Action to take when the diff contains at least one unmapped file
+ * (non-Java, outside configured source/test dirs). One of
+ * {@code "selected"}, {@code "full_suite"}, {@code "skipped"}. When
+ * set to {@code "selected"} the engine treats the unmapped file as
+ * if it weren't there and continues to discovery — matching
+ * pre-v2 {@code runAllOnNonJavaChange=false} behaviour.
+ *
+ * @return the on-unmapped-file property
+ */
+ public abstract Property getOnUnmappedFile();
+
+ /**
+ * Action to take when discovery completes but returns no tests. One
+ * of {@code "selected"}, {@code "full_suite"}, {@code "skipped"}.
+ *
+ * @return the on-discovery-empty property
+ */
+ public abstract Property getOnDiscoveryEmpty();
}
diff --git a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsPlugin.java b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsPlugin.java
index 7739370..ffb242e 100644
--- a/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsPlugin.java
+++ b/affected-tests-gradle/src/main/java/io/affectedtests/gradle/AffectedTestsPlugin.java
@@ -28,16 +28,25 @@ public void apply(Project project) {
);
extension.getIncludeUncommitted().convention(true);
extension.getIncludeStaged().convention(true);
- extension.getRunAllIfNoMatches().convention(false);
- extension.getRunAllOnNonJavaChange().convention(true);
+ // No conventions for the two legacy booleans — leaving them
+ // unset is the v2 signal that the caller has not overridden the
+ // defaults, which lets the core config resolver apply
+ // mode-based defaults instead of always shimming the pre-v2
+ // boolean translation. Zero-config users still get pre-v2
+ // behaviour because the core builder's hardcoded fallbacks
+ // match the old convention values 1:1.
extension.getStrategies().convention(List.of("naming", "usage", "impl", "transitive"));
- extension.getTransitiveDepth().convention(2);
+ extension.getTransitiveDepth().convention(4);
extension.getTestSuffixes().convention(List.of("Test", "IT", "ITTest", "IntegrationTest"));
extension.getSourceDirs().convention(List.of("src/main/java"));
extension.getTestDirs().convention(List.of("src/test/java"));
- extension.getExcludePaths().convention(List.of("**/generated/**"));
+ // No convention for excludePaths / ignorePaths: an empty Gradle
+ // provider is how we signal "let the core config builder pick
+ // the v2 default list" (which is broader than the pre-v2
+ // single-entry default). Setting a convention here would pin
+ // the pre-v2 narrow default even on zero-config installs.
extension.getIncludeImplementationTests().convention(true);
- extension.getImplementationNaming().convention(List.of("Impl"));
+ extension.getImplementationNaming().convention(List.of("Impl", "Default"));
Project rootProject = project.getRootProject();
Directory rootDir = rootProject.getLayout().getProjectDirectory();
@@ -57,8 +66,17 @@ public void apply(Project project) {
task.getSourceDirs().set(extension.getSourceDirs());
task.getTestDirs().set(extension.getTestDirs());
task.getExcludePaths().set(extension.getExcludePaths());
+ task.getIgnorePaths().set(extension.getIgnorePaths());
+ task.getOutOfScopeTestDirs().set(extension.getOutOfScopeTestDirs());
+ task.getOutOfScopeSourceDirs().set(extension.getOutOfScopeSourceDirs());
task.getIncludeImplementationTests().set(extension.getIncludeImplementationTests());
task.getImplementationNaming().set(extension.getImplementationNaming());
+ task.getMode().set(extension.getMode());
+ task.getOnEmptyDiff().set(extension.getOnEmptyDiff());
+ task.getOnAllFilesIgnored().set(extension.getOnAllFilesIgnored());
+ task.getOnAllFilesOutOfScope().set(extension.getOnAllFilesOutOfScope());
+ task.getOnUnmappedFile().set(extension.getOnUnmappedFile());
+ task.getOnDiscoveryEmpty().set(extension.getOnDiscoveryEmpty());
task.getRootDir().set(rootDir);
task.getSubprojectPaths().set(project.provider(() -> collectSubprojectPaths(rootProject)));
diff --git a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java
index 0540cc3..38aa487 100644
--- a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java
+++ b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestTaskLogFormatTest.java
@@ -2,6 +2,8 @@
import io.affectedtests.core.AffectedTestsEngine.AffectedTestsResult;
import io.affectedtests.core.AffectedTestsEngine.EscalationReason;
+import io.affectedtests.core.config.Action;
+import io.affectedtests.core.config.Situation;
import org.junit.jupiter.api.Test;
import org.slf4j.helpers.MessageFormatter;
@@ -37,6 +39,9 @@ void nonEscalatedSelectionRendersProductionAndTestCounts() {
Set.of("com.example.Foo"),
Set.of(),
false,
+ false,
+ Situation.DISCOVERY_SUCCESS,
+ Action.SELECTED,
EscalationReason.NONE);
String summary = render(AffectedTestTask.renderSummary(result));
@@ -59,6 +64,9 @@ void nonJavaEscalationNamesTheRealTrigger() {
Set.of(),
Set.of(),
true,
+ false,
+ Situation.UNMAPPED_FILE,
+ Action.FULL_SUITE,
EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
String summary = render(AffectedTestTask.renderSummary(result));
@@ -85,6 +93,9 @@ void emptyChangesetEscalationNamesItsOwnTrigger() {
Set.of(),
Set.of(),
true,
+ false,
+ Situation.EMPTY_DIFF,
+ Action.FULL_SUITE,
EscalationReason.RUN_ALL_ON_EMPTY_CHANGESET);
String summary = render(AffectedTestTask.renderSummary(result));
@@ -107,6 +118,9 @@ void postDiscoveryEmptyEscalationDistinguishesItselfFromEmptyChangeset() {
Set.of("com.example.Orphan"),
Set.of(),
true,
+ false,
+ Situation.DISCOVERY_EMPTY,
+ Action.FULL_SUITE,
EscalationReason.RUN_ALL_IF_NO_MATCHES);
String summary = render(AffectedTestTask.renderSummary(result));
@@ -124,14 +138,18 @@ void pluralisationIsConsistentAcrossSummaryLines() {
Set.of(), Map.of(),
Set.of("src/main/resources/application.yml"),
Set.of(), Set.of(),
- true, EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
+ true, false,
+ Situation.UNMAPPED_FILE, Action.FULL_SUITE,
+ EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
AffectedTestsResult normal = 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(),
- false, EscalationReason.NONE);
+ false, false,
+ Situation.DISCOVERY_SUCCESS, Action.SELECTED,
+ EscalationReason.NONE);
// Both branches must use the same "file(s)" form; otherwise CI logs
// drift between `changed file(s)` and `changed files` and a grep for
@@ -158,14 +176,18 @@ void formatPlaceholderCountMatchesArgsLengthOnBothBranches() {
Set.of(), Map.of(),
Set.of("src/main/resources/application.yml"),
Set.of(), Set.of(),
- true, EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
+ true, false,
+ Situation.UNMAPPED_FILE, Action.FULL_SUITE,
+ EscalationReason.RUN_ALL_ON_NON_JAVA_CHANGE);
AffectedTestsResult normal = 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(),
- false, EscalationReason.NONE);
+ false, false,
+ Situation.DISCOVERY_SUCCESS, Action.SELECTED,
+ EscalationReason.NONE);
AffectedTestTask.LogLine escalatedLine = AffectedTestTask.renderSummary(escalated);
AffectedTestTask.LogLine normalLine = AffectedTestTask.renderSummary(normal);
diff --git a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestsPluginTest.java b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestsPluginTest.java
index fdc0f78..f538ccc 100644
--- a/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestsPluginTest.java
+++ b/affected-tests-gradle/src/test/java/io/affectedtests/gradle/AffectedTestsPluginTest.java
@@ -37,20 +37,29 @@ void extensionHasDefaults() {
assertEquals("origin/master", ext.getBaseRef().get());
assertTrue(ext.getIncludeUncommitted().get());
assertTrue(ext.getIncludeStaged().get());
- assertFalse(ext.getRunAllIfNoMatches().get());
- // The "run more, never run less" default: any non-Java or unmapped file
- // in the diff must escalate to a full suite. Flipping this default
- // silently would be a material behaviour change, so pin it in a test.
- assertTrue(ext.getRunAllOnNonJavaChange().get(),
- "runAllOnNonJavaChange must default to true to preserve the safety escalation");
- // Defaults: naming, usage, impl, transitive — transitive is gated by the
- // strategies list (see I9 in the review), not just by transitiveDepth.
+ // v2: no convention for the legacy booleans — leaving them unset
+ // is the signal the core builder uses to fall through to
+ // mode-based defaults. Zero-config users still observe pre-v2
+ // behaviour because the builder's hard-coded fallbacks match the
+ // old convention values 1:1 (see AffectedTestsConfigTest).
+ assertFalse(ext.getRunAllIfNoMatches().isPresent(),
+ "v2 must not install a convention for runAllIfNoMatches; the core config resolver does the translation");
+ assertFalse(ext.getRunAllOnNonJavaChange().isPresent(),
+ "v2 must not install a convention for runAllOnNonJavaChange; the core config resolver does the translation");
assertEquals(4, ext.getStrategies().get().size());
assertTrue(ext.getStrategies().get().contains("transitive"));
- assertEquals(2, ext.getTransitiveDepth().get());
+ // v2 raises the default transitive depth from 2 to 4 — real-world
+ // ctrl -> svc -> repo -> mapper chains sit at 3-4 levels, so 2
+ // silently dropped coverage on zero-config installs.
+ assertEquals(4, ext.getTransitiveDepth().get());
assertEquals(4, ext.getTestSuffixes().get().size());
assertTrue(ext.getIncludeImplementationTests().get());
- assertEquals(1, ext.getImplementationNaming().get().size());
+ // v2 adds the "Default" prefix pattern alongside "Impl" so
+ // DefaultFooService → FooService is also picked up by the
+ // implementation strategy on zero-config installs.
+ assertEquals(2, ext.getImplementationNaming().get().size());
+ assertTrue(ext.getImplementationNaming().get().contains("Impl"));
+ assertTrue(ext.getImplementationNaming().get().contains("Default"));
}
@Test
diff --git a/docs/architecture.mmd b/docs/architecture.mmd
index f548968..6e211d7 100644
--- a/docs/architecture.mmd
+++ b/docs/architecture.mmd
@@ -1,48 +1,66 @@
flowchart TD
Start(["git diff baseRef..HEAD + uncommitted + staged"]):::io --> Changed{Any changed files?}:::core
- Changed -->|no| EmptyGate{runAllIfNoMatches?}:::core
- EmptyGate -->|true| RunAllEmpty[[runAll = true reason = RUN_ALL_ON_EMPTY_CHANGESET]]:::warn
- EmptyGate -->|false default| Exit0[Exit 0 · skip tests]:::muted
+ Changed -->|no| SitEmpty[/Situation EMPTY_DIFF/]:::situation
+ Changed -->|yes| Mapper{{PathToClassMapper buckets each path}}:::core
- Changed -->|yes| Mapper{{PathToClassMapper}}:::core
+ Mapper -->|matches ignorePaths| BucketIgnored[ignored bucket *.md · LICENSE · generated]:::muted
+ Mapper -->|under outOfScope TestDirs / SourceDirs| BucketOOS[out-of-scope bucket api-test · gatling]:::muted
+ Mapper -->|".java under sourceDirs"| BucketProd[production bucket changed class FQNs]:::data
+ Mapper -->|".java under testDirs"| BucketTest[test bucket directly changed test FQNs]:::data
+ Mapper -->|everything else yml · gradle · migrations| BucketUnmapped[unmapped bucket]:::warn
- Mapper -->|".java under sourceDirs"| Prod[Changed production class FQNs]:::data
- Mapper -->|".java under testDirs"| Direct[Directly changed test FQNs]:::data
- Mapper -->|matches excludePaths| Excluded[Silently dropped explicit opt-out]:::muted
- Mapper -->|"non-Java or .java outside configured dirs"| Unmapped[Unmapped files yaml · gradle · migrations]:::warn
+ BucketIgnored --> Classify{{"Classify situation"}}:::core
+ BucketOOS --> Classify
+ BucketProd --> Classify
+ BucketTest --> Classify
+ BucketUnmapped --> Classify
- Unmapped --> NonJavaGate{runAllOnNonJavaChange?}:::core
- NonJavaGate -->|true default| RunAllNonJava[[runAll = true reason = RUN_ALL_ON_NON_JAVA_CHANGE]]:::warn
- NonJavaGate -->|false opt-out| Skip[Silent skip legacy behaviour]:::muted
+ Classify -->|"all files ignored"| SitAllIgnored[/Situation ALL_FILES_IGNORED/]:::situation
+ Classify -->|"all files out of scope"| SitAllOOS[/Situation ALL_FILES_OUT_OF_SCOPE/]:::situation
+ Classify -->|"unmapped non-empty"| SitUnmapped[/Situation UNMAPPED_FILE/]:::situation
+ Classify -->|"only in-scope .java"| Engine[[AffectedTestsEngine 4 strategies · merged]]:::core
- Prod --> Engine[[AffectedTestsEngine 4 strategies · merged]]:::core
Engine --> Naming[naming Foo → FooTest / FooIT]:::strat
Engine --> Usage["usage JavaParser: imports + type refs"]:::strat
- Engine --> Impl[impl subtypes of changed interfaces]:::strat
- Engine --> Transitive[transitive reverse-dep walk · N levels]:::strat
+ Engine --> Impl["impl subtypes of changed interfaces (Impl / Default prefixes)"]:::strat
+ Engine --> Transitive["transitive reverse-dep walk · depth 4"]:::strat
- Naming --> Union[Union of affected test class FQNs]:::data
+ Naming --> Union[Union of affected test FQNs]:::data
Usage --> Union
Impl --> Union
Transitive --> Union
- Direct --> Union
+ BucketTest --> Union
Union --> UnionGate{Union empty?}:::core
- UnionGate -->|no| Router[Group FQNs by owning Gradle subproject]:::core
- UnionGate -->|yes| DiscoveryEmptyGate{runAllIfNoMatches?}:::core
- DiscoveryEmptyGate -->|true| RunAllDiscovery[[runAll = true reason = RUN_ALL_IF_NO_MATCHES]]:::warn
- DiscoveryEmptyGate -->|false default| Exit0
+ UnionGate -->|yes| SitDiscoveryEmpty[/Situation DISCOVERY_EMPTY/]:::situation
+ UnionGate -->|no| SitDiscoverySuccess[/Situation DISCOVERY_SUCCESS/]:::situation
- Router --> Dispatch[":moduleA:test --tests FooTest :moduleB:test --tests BarTest"]:::io
+ SitEmpty --> Resolve
+ SitAllIgnored --> Resolve
+ SitAllOOS --> Resolve
+ SitUnmapped --> Resolve
+ SitDiscoveryEmpty --> Resolve
+ SitDiscoverySuccess --> Resolve
+
+ Resolve{{"Resolve Action (priority order)"}}:::core
+ Resolve --> Explicit["1 · explicit onXxx setting"]:::priority
+ Explicit --> Legacy["2 · legacy boolean (runAllIfNoMatches / runAllOnNonJavaChange)"]:::priority
+ Legacy --> ModeDefault["3 · mode default (auto · local · ci · strict)"]:::priority
+ ModeDefault --> Hardcoded["4 · pre-v2 hardcoded default"]:::priority
+ Hardcoded --> Action{Action}:::core
- RunAllEmpty --> FullSuite
- RunAllNonJava --> FullSuite
- RunAllDiscovery --> FullSuite["gradle test full suite · no --tests filter"]:::io
+ Action -->|SELECTED| Router[Group FQNs by owning Gradle subproject]:::core
+ Action -->|FULL_SUITE| FullSuite["gradle test full suite · no --tests filter"]:::io
+ Action -->|SKIPPED| Exit0[Exit 0 · skip tests]:::muted
+
+ Router --> Dispatch[":moduleA:test --tests FooTest :moduleB:test --tests BarTest"]:::io
classDef io fill:#1f2937,color:#f9fafb,stroke:#374151,stroke-width:1px
classDef core fill:#1d4ed8,color:#ffffff,stroke:#1e3a8a,stroke-width:1px
classDef data fill:#047857,color:#ffffff,stroke:#064e3b,stroke-width:1px
classDef strat fill:#0e7490,color:#ffffff,stroke:#155e75,stroke-width:1px
+ classDef situation fill:#7c3aed,color:#ffffff,stroke:#5b21b6,stroke-width:1px
+ classDef priority fill:#0f766e,color:#ffffff,stroke:#115e59,stroke-width:1px
classDef warn fill:#b45309,color:#ffffff,stroke:#78350f,stroke-width:1px
classDef muted fill:#4b5563,color:#e5e7eb,stroke:#374151,stroke-width:1px
diff --git a/docs/architecture.svg b/docs/architecture.svg
index 3c2ec88..4263f97 100644
--- a/docs/architecture.svg
+++ b/docs/architecture.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file