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