From 5316b52a64b115824ef3591b59945e03b7960d7a Mon Sep 17 00:00:00 2001 From: vedanthvasudev Date: Wed, 22 Apr 2026 08:54:21 +0100 Subject: [PATCH] feat/v2-situation-config: Introduce Situation/Action/Mode model for v2 decision tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit realises Phase 1 of the v2 design (docs/DESIGN-v2.md) by replacing the two legacy boolean flags (`runAllIfNoMatches`, `runAllOnNonJavaChange`) with a richer per-situation decision model so every user who adopts the plugin can express "selected / full suite / skipped" on each distinct branch the engine can take, without giving up the existing safety net. Three new enums — `Action` (SELECTED, FULL_SUITE, SKIPPED), `Situation` (EMPTY_DIFF, ALL_FILES_IGNORED, ALL_FILES_OUT_OF_SCOPE, UNMAPPED_FILE, DISCOVERY_EMPTY, DISCOVERY_SUCCESS) and `Mode` (AUTO, LOCAL, CI, STRICT) — now drive `AffectedTestsEngine`, which has been rewritten to follow a single top-to-bottom decision tree: ignore the diff → bucket files into ignored / out-of-scope / production / test / unmapped → resolve the matching `Situation` → look up the configured `Action` and either short-circuit (FULL_SUITE or SKIPPED) or continue into discovery. This makes the common cases visible from the log line itself and removes the hidden coupling between the two legacy flags. `PathToClassMapper` and `ProjectIndex` gained first-class support for two new path categories — `ignorePaths` (files that must not influence test selection, e.g. markdown/generated) and `outOfScopeTestDirs` / `outOfScopeSourceDirs` (source sets like `api-test` that are built by a separate Gradle task and must not be dispatched here). The api-test-only regression that used to trigger a full unit-test run now routes to `ALL_FILES_OUT_OF_SCOPE → SKIPPED`, and a docs-only diff now routes to `ALL_FILES_IGNORED → SKIPPED` on zero-config installs because the default ignore list covers `*.md`, `*.txt`, LICENSE, CHANGELOG and common image formats at both the repository root and nested paths. `AffectedTestsConfig` resolves each `Situation → Action` with a strict precedence so nobody silently regresses: explicit v2 settings win; then the two legacy booleans translate into `UNMAPPED_FILE` and `DISCOVERY_EMPTY` actions for users who haven't migrated yet; then the `Mode` profile (CI biases toward FULL_SUITE, LOCAL toward SKIPPED, STRICT is paranoid, AUTO sniffs the `CI` env var) supplies defaults; and finally the pre-v2 hardcoded defaults (`runAllIfNoMatches=true`, `runAllOnNonJavaChange=true`) apply so zero-config users keep their current safety net. Default `transitiveDepth` is raised to 4 to match typical controller→service→repo chains, `implementationNaming` now includes the `Default` prefix idiomatic in Spring code, and the legacy `excludePaths` getter is preserved but aliases `ignorePaths`. The Gradle extension exposes `mode`, `ignorePaths`, `outOfScopeTestDirs`, `outOfScopeSourceDirs` and per-situation overrides (`onEmptyDiff`, `onAllFilesIgnored`, `onAllFilesOutOfScope`, `onUnmappedFile`, `onDiscoveryEmpty`), and the task's escalation log now names both the v2 decision and the legacy flag name for grep compatibility. Tests cover the new routing, the legacy shim, mode defaults, default ignore-path glob coverage at the repo root and the api-test-only scenario end-to-end; the full plugin `./gradlew check` passes with 87 tests. The README and architecture diagram are rewritten alongside the code so new users start with the v2 API (mode, per-situation actions, path buckets, the resolution-priority ladder) and existing users see their legacy flags mapped explicitly to the v2 situations they translate to. The rendered diagram in `docs/architecture.svg` now shows the five decision branches and the four-tier action-resolution ladder. --- README.md | 171 ++++++-- .../core/AffectedTestsEngine.java | 227 +++++++--- .../io/affectedtests/core/config/Action.java | 37 ++ .../core/config/AffectedTestsConfig.java | 395 +++++++++++++++++- .../io/affectedtests/core/config/Mode.java | 41 ++ .../affectedtests/core/config/Situation.java | 63 +++ .../core/discovery/ProjectIndex.java | 72 +++- .../core/mapping/PathToClassMapper.java | 96 ++++- .../core/AffectedTestsEngineTest.java | 100 +++++ .../core/config/AffectedTestsConfigTest.java | 111 ++++- .../core/mapping/PathToClassMapperTest.java | 50 ++- .../gradle/AffectedTestTask.java | 222 ++++++++-- .../gradle/AffectedTestsExtension.java | 108 ++++- .../gradle/AffectedTestsPlugin.java | 28 +- .../gradle/AffectedTestTaskLogFormatTest.java | 30 +- .../gradle/AffectedTestsPluginTest.java | 29 +- docs/architecture.mmd | 66 +-- docs/architecture.svg | 2 +- 18 files changed, 1637 insertions(+), 211 deletions(-) create mode 100644 affected-tests-core/src/main/java/io/affectedtests/core/config/Action.java create mode 100644 affected-tests-core/src/main/java/io/affectedtests/core/config/Mode.java create mode 100644 affected-tests-core/src/main/java/io/affectedtests/core/config/Situation.java 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.

- Affected Tests architecture: git diff feeds PathToClassMapper, which routes Java files into 4 discovery strategies for per-module test dispatch; two safety gates (runAllIfNoMatches on empty changesets or empty discovery, runAllOnNonJavaChange on unmapped files) escalate to a full run + Affected Tests v2 architecture: git diff feeds PathToClassMapper which buckets each changed file as ignored, out-of-scope, production Java, test Java, or unmapped; the resulting Situation (EMPTY_DIFF, ALL_FILES_IGNORED, ALL_FILES_OUT_OF_SCOPE, UNMAPPED_FILE, DISCOVERY_EMPTY, DISCOVERY_SUCCESS) maps to an Action (SELECTED, FULL_SUITE, SKIPPED) resolved in priority order from explicit onXxx settings, legacy booleans, mode defaults, and pre-v2 hardcoded defaults

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): *

    *
  1. Detect changed files via JGit ({@code baseRef..HEAD} + uncommitted/staged)
  2. - *
  3. Map file paths to production and test class FQNs
  4. - *
  5. Run all enabled discovery strategies (naming, usage, impl, transitive) - * and merge their results. Scanning is recursive — modules at any nesting - * depth are discovered automatically.
  6. + *
  7. Map file paths into five mutually-exclusive buckets: ignored, + * out-of-scope, production, test, unmapped
  8. + *
  9. 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.
  10. + *
  11. For "ambiguous" situations (empty diff, all-ignored, all-out-of-scope, + * unmapped) the {@link Action} from {@link AffectedTestsConfig#actionFor} + * is applied directly — no discovery.
  12. + *
  13. 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}.
  14. *
  15. Filter the union against test classes that actually exist on disk so * deleted/renamed tests don't reach the downstream {@code test} task
  16. - *
  17. Return the filtered FQN set together with the file path of each test, - * so callers can route per-module test invocations correctly
  18. *
*/ 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=true} → {@link Situation#UNMAPPED_FILE} + * resolves to {@link Action#FULL_SUITE}.
  • + *
  • {@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: + *
    + *
  1. the caller's explicit {@code on*} action,
  2. + *
  3. the translation of whichever legacy boolean that situation + * was historically driven by,
  4. + *
  5. the per-mode default (only when the caller set an explicit + * mode — {@code AUTO}/unset falls through to the final branch),
  6. + *
  7. the hard-coded pre-v2 default, kept identical to the legacy + * boolean defaults so zero-config callers continue to observe + * pre-v2 behaviour exactly.
  8. + *
+ */ + 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_DIFFALL_IGNOREDALL_OUT_OF_SCOPEUNMAPPED_FILEDISCOVERY_EMPTY
LOCALSKIPPEDSKIPPEDSKIPPEDFULL_SUITESKIPPED
CISKIPPEDSKIPPEDSKIPPEDFULL_SUITEFULL_SUITE
STRICTFULL_SUITEFULL_SUITESKIPPEDFULL_SUITEFULL_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

true

false default

yes

.java under sourceDirs

.java under testDirs

matches excludePaths

non-Java or .java
outside configured dirs

true default

false opt-out

no

yes

true

false default

git diff baseRef..HEAD
+ uncommitted + staged

Any changed
files?

runAllIfNoMatches?

runAll = true
reason = RUN_ALL_ON_EMPTY_CHANGESET

Exit 0 · skip tests

PathToClassMapper

Changed production
class FQNs

Directly changed
test FQNs

Silently dropped
explicit opt-out

Unmapped files
yaml · gradle · migrations

runAllOnNonJavaChange?

runAll = true
reason = RUN_ALL_ON_NON_JAVA_CHANGE

Silent skip
legacy behaviour

AffectedTestsEngine
4 strategies · merged

naming
Foo → FooTest / FooIT

usage
JavaParser: imports + type refs

impl
subtypes of changed interfaces

transitive
reverse-dep walk · N levels

Union of affected
test class FQNs

Union empty?

Group FQNs by
owning Gradle subproject

runAllIfNoMatches?

runAll = true
reason = RUN_ALL_IF_NO_MATCHES

:moduleA:test --tests FooTest
:moduleB:test --tests BarTest

gradle test
full suite · no --tests filter

\ No newline at end of file +

no

yes

matches
ignorePaths

under outOfScope
TestDirs / SourceDirs

.java under
sourceDirs

.java under
testDirs

everything else
yml · gradle · migrations

all files ignored

all files out of scope

unmapped non-empty

only in-scope .java

yes

no

SELECTED

FULL_SUITE

SKIPPED

git diff baseRef..HEAD
+ uncommitted + staged

Any changed
files?

Situation
EMPTY_DIFF

PathToClassMapper
buckets each path

ignored bucket
*.md · LICENSE · generated

out-of-scope bucket
api-test · gatling

production bucket
changed class FQNs

test bucket
directly changed test FQNs

unmapped bucket

Classify situation

Situation
ALL_FILES_IGNORED

Situation
ALL_FILES_OUT_OF_SCOPE

Situation
UNMAPPED_FILE

AffectedTestsEngine
4 strategies · merged

naming
Foo → FooTest / FooIT

usage
JavaParser: imports + type refs

impl
subtypes of changed interfaces
(Impl / Default prefixes)

transitive
reverse-dep walk · depth 4

Union of
affected test FQNs

Union empty?

Situation
DISCOVERY_EMPTY

Situation
DISCOVERY_SUCCESS

Resolve Action
(priority order)

1 · explicit onXxx setting

2 · legacy boolean
(runAllIfNoMatches / runAllOnNonJavaChange)

3 · mode default
(auto · local · ci · strict)

4 · pre-v2 hardcoded default

Action

Group FQNs by
owning Gradle subproject

gradle test
full suite · no --tests filter

Exit 0 · skip tests

:moduleA:test --tests FooTest
:moduleB:test --tests BarTest

\ No newline at end of file