From 786410117faf363f20c382401b430dd331e3e521 Mon Sep 17 00:00:00 2001 From: vedanthvasudev Date: Thu, 23 Apr 2026 11:01:20 +0100 Subject: [PATCH] feat/v2.1-polish-dsl-errors: sharpen v2 config-error UX + pin v2.1.0 release via `.release-version` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small-but-sharp fixes that surfaced during the v2.0.0-local pilot against security-service, bundled into one PR because they all share the same goal: close the gap between "something in my build.gradle is wrong" and "the plugin told me what to change". Two are DSL-error polish, the third teaches the release workflow a new version-override mechanism so this exact release can ship as v2.1.0 on a merge-to-master push without needing workflow_dispatch coordination. 1. Targeted v2 migration hint on the three removed legacy knobs. Adds `setRunAllIfNoMatches` / `setRunAllOnNonJavaChange` / `setExcludePaths` shims on `AffectedTestsExtension` that intercept the Groovy DSL assignment and throw a `GradleException` naming both the removed v1 knob and its v2 replacement(s) — pointing at the exact onXxx action, the mode shortcut where one exists, and the CHANGELOG section for the full table. Kotlin DSL callers already get a compile error naming the removed property so the shims only fire in Groovy, which is where the unhelpful default error lived. 2. `gradlewTimeoutSeconds` range check at configuration time. The v1.9.22 check (value >= 0, 0 disables) lived on the core builder and only ran at task-execution, which meant IDE sync / `./gradlew help` / `./gradlew tasks` all stayed green against a misconfigured build and the operator only saw the rejection when they tried to run affectedTest. Adds a mirror check in the plugin's `project.afterEvaluate` so the same message fires at configuration end. Builder-side check stays as belt-and-braces for programmatic callers that bypass the DSL extension. 3. `.release-version` override file in `.github/workflows/release.yml`. Until now, shipping a non-patch bump on a merge-to-master release required running the release workflow via `workflow_dispatch` with an explicit `version` input. That meant every minor release had a coordination window where the auto-release on the merge push could mint an unwanted patch tag before the manual dispatch took over. The workflow now reads a `.release-version` SemVer file at repo root (validated via regex before use) when no dispatch input is present, passes it to axion-release, and deletes the file in a `[skip ci]` follow-up commit after the tag/publish/release triad succeeds. This PR commits `.release-version = 2.1.0` so its own merge ships as v2.1.0 on the portal; the file self-cleans afterwards so subsequent patch releases resume auto-incrementing. Regression coverage for the DSL fixes is pinned in `AffectedTestsPluginTest`: - `legacyKnobAssignmentThrowsWithV2MigrationHint_runAllIfNoMatches` - `legacyKnobAssignmentThrowsWithV2MigrationHint_runAllOnNonJavaChange` - `legacyKnobAssignmentThrowsWithV2MigrationHint_excludePaths` - `negativeGradlewTimeoutFailsAtConfigurationTime` Each assertion pins both the user-facing knob name AND the v2 replacement / valid-range bound, so a well-intentioned "tidy up the error message" refactor that drops either half is caught by the test suite rather than rediscovered by the next migrating adopter. The release-workflow logic was simulated end-to-end against nine input cases (input-wins, file-used, trailing-newline trimmed, empty file rejected, bad SemVer rejected, pre-release allowed, etc.) to verify branching before relying on it to ship v2.1.0 itself. CHANGELOG calls v2.1.0 out as the first publicly tagged v2 release, bundling the v2.0 legacy-knob removal (already in master under the v1.9.23 tag the auto-patch-incrementer minted) with these polish fixes. --- .github/workflows/release.yml | 81 ++++++++++++-- .release-version | 1 + CHANGELOG.md | 101 ++++++++++++++++++ README.md | 19 +++- .../gradle/AffectedTestsExtension.java | 79 +++++++++++++- .../gradle/AffectedTestsPlugin.java | 16 +++ .../gradle/AffectedTestsPluginTest.java | 101 ++++++++++++++++++ 7 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 .release-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afae73d..6ea5ba0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,25 +58,62 @@ jobs: echo "already_tagged=false" >> "$GITHUB_OUTPUT" fi + # Pick the release version. Three sources in priority order: + # + # 1. `workflow_dispatch` input — for ad-hoc manual releases. + # Wins over everything because it's the most explicit. + # 2. A `.release-version` file committed at repo root — for + # pre-committing the next minor/major on a push-to-master + # release without needing manual dispatch coordination. The + # consuming PR lands the file in the same commit that wants + # the non-default version; this workflow picks it up, tags + # that version, then deletes the file in a follow-up commit + # so subsequent pushes fall back to auto-patch-increment. + # 3. Neither — axion-release auto-increments the patch number + # off the latest tag. Normal ongoing behaviour. + # + # The file path is validated as a SemVer string before use so a + # malformed value blocks the release at config time rather than + # cutting a bogus tag. + - name: Resolve explicit release version + id: version_override + run: | + if [[ -n "$RELEASE_VERSION_INPUT" ]]; then + echo "Using workflow_dispatch input: $RELEASE_VERSION_INPUT" + echo "source=input" >> "$GITHUB_OUTPUT" + echo "value=$RELEASE_VERSION_INPUT" >> "$GITHUB_OUTPUT" + elif [[ -f .release-version ]]; then + V=$(tr -d '[:space:]' < .release-version) + if [[ -z "$V" ]]; then + echo "::error::.release-version file exists but is empty" + exit 1 + fi + if ! [[ "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "::error::.release-version value '$V' is not SemVer (MAJOR.MINOR.PATCH[-prerelease])" + exit 1 + fi + echo "Using .release-version file: $V" + echo "source=file" >> "$GITHUB_OUTPUT" + echo "value=$V" >> "$GITHUB_OUTPUT" + else + echo "No explicit version — axion-release will auto-increment patch." + echo "source=auto" >> "$GITHUB_OUTPUT" + fi + # Only create a new tag if HEAD is not already tagged. If a previous run # tagged successfully but failed during publish, re-running this workflow # will skip tagging and proceed straight to the "publish if missing" step # below — that's what makes the pipeline idempotent. - # - # Version override: on workflow_dispatch with a non-empty `version` - # input, pass `-Prelease.version=` so axion-release tags that - # exact version instead of auto-incrementing the patch. On push events, - # `RELEASE_VERSION_INPUT` is empty and the call stays identical to the - # pre-override form. - name: Release (tag and push) if: steps.tag_check.outputs.already_tagged == 'false' + env: + VERSION_OVERRIDE: ${{ steps.version_override.outputs.value }} + VERSION_OVERRIDE_SOURCE: ${{ steps.version_override.outputs.source }} run: | VERSION_ARG=() - if [[ -n "$RELEASE_VERSION_INPUT" ]]; then - echo "Explicit release version requested: $RELEASE_VERSION_INPUT" - VERSION_ARG=("-Prelease.version=$RELEASE_VERSION_INPUT") - else - echo "No explicit version — axion-release will auto-increment patch." + if [[ "$VERSION_OVERRIDE_SOURCE" != "auto" ]]; then + echo "Explicit release version requested (source=$VERSION_OVERRIDE_SOURCE): $VERSION_OVERRIDE" + VERSION_ARG=("-Prelease.version=$VERSION_OVERRIDE") fi ./gradlew release -Prelease.pushTagsOnly \ "${VERSION_ARG[@]}" \ @@ -140,3 +177,25 @@ jobs: else echo "GitHub Release $TAG already exists — skipping" fi + + # If the release version came from a `.release-version` file, delete + # the file now that the tag has been pushed, the plugin has been + # published, and the GitHub Release exists. Leaving the file in place + # would either re-ship the same version on the next push (rejected + # by Plugin Portal → failed job) or starve auto-patch-increment. + # + # The cleanup commit is tagged `[skip ci]` so this workflow does + # not trigger itself in a loop. Only runs when we actually tagged + # (already_tagged == 'false') AND the version came from the file — + # a workflow_dispatch input override must not consume the file, and + # a rerun against an already-tagged HEAD must not touch the tree. + - name: Consume .release-version override + if: steps.version_override.outputs.source == 'file' && steps.tag_check.outputs.already_tagged == 'false' + env: + TAG: ${{ steps.released.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .release-version + git commit -m "chore(release): consume .release-version after shipping $TAG [skip ci]" + git push origin "HEAD:$GH_REF_NAME" diff --git a/.release-version b/.release-version new file mode 100644 index 0000000..7ec1d6d --- /dev/null +++ b/.release-version @@ -0,0 +1 @@ +2.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 407f80c..b8c435d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,107 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [v2.1.0] — DSL polish on top of the v2 breaking release + +v2.1.0 is the **first publicly tagged v2 release**. It bundles everything +the v2.0 branch landed in master (the legacy-knob removal below) with two +small-but-sharp polish fixes that surfaced during a real-world pilot of +v2.0 against a Modulr micro-service. The polish fixes are strictly +additive on top of v2.0 behaviour — operators already on the v2 DSL see +no functional change, only earlier and clearer errors when something is +misconfigured. + +### Changed — targeted error messages when v1 knobs appear in build.gradle + +Before v2.1, a v1 user who dropped `runAllIfNoMatches = false` into their +`affectedTests { }` block got Gradle's default unknown-property error: + +``` +> Could not set unknown property 'runAllIfNoMatches' for extension + 'affectedTests' of type io.affectedtests.gradle.AffectedTestsExtension. +``` + +Correct, but unhelpful — it names the removed knob without naming the v2 +replacement, forcing the operator to grep the CHANGELOG to find the fix. + +v2.1 intercepts the assignment on the extension and swaps that generic +error for a targeted migration hint pointing at the exact `onXxx` knob +that took over each responsibility: + +``` +> affectedTests.runAllIfNoMatches was removed in v2.0.0. Use + onEmptyDiff = "full_suite" and/or onDiscoveryEmpty = "full_suite" + instead (or set mode = "ci" / "strict" to get those defaults). See + CHANGELOG.md v2.0 for the full migration table. +``` + +Matching shims for `runAllOnNonJavaChange` and `excludePaths` point at +`onUnmappedFile` and at the `ignorePaths` vs `outOfScopeTestDirs` +distinction respectively. The v1 names are still rejected — v2 does not +re-introduce the knobs — but the rejection is now actionable. + +Kotlin DSL callers already got a compile error naming the removed +property, so these shims only fire in Groovy DSL, which is where the +generic error lived. + +Regression coverage: + +- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_runAllIfNoMatches` +- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_runAllOnNonJavaChange` +- `AffectedTestsPluginTest.legacyKnobAssignmentThrowsWithV2MigrationHint_excludePaths` + +Each pins both the v1 knob name *and* the v2 replacement name in the +error text, so a well-intentioned "tidy up the error message" refactor +that drops either half gets caught. + +### Added — `.release-version` override file for merge-to-master minor/major releases + +Until v2.1, the only way to ship a minor/major bump instead of an +auto-patch-increment was to run the release workflow via +`workflow_dispatch` with an explicit `version` input. That meant every +minor release required coordination between "merge the PR" and +"manually trigger the workflow" — and if the auto-release on the merge +push got there first, it would mint an unwanted patch tag the operator +then had to work around. + +v2.1 teaches `.github/workflows/release.yml` a third version source: a +`.release-version` file committed at repo root. When the merge-to-master +release workflow finds the file, it reads the SemVer string, validates +it, and tags that exact version instead of auto-incrementing the patch. +After a successful publish the workflow deletes the file in a follow-up +commit tagged `[skip ci]` (so the cleanup push doesn't re-trigger the +release), which keeps ongoing patch releases on auto-increment. + +Priority order on `./gradlew release`: + +1. `workflow_dispatch` `version` input (manual dispatch — unchanged). +2. `.release-version` file at repo root (new in v2.1). +3. Auto-patch-increment (default — unchanged). + +This release itself uses the file mechanism to ship as `v2.1.0` on +top of the auto-patch-increment line that would otherwise have cut +`v1.9.24`. See README §Versioning for the decision matrix. + +### Changed — `gradlewTimeoutSeconds` range check moved to configuration time + +The `gradlewTimeoutSeconds >= 0` check added in v1.9.22 lived on the +core config builder, which is only invoked at task-execution time. That +meant an invalid value like `gradlewTimeoutSeconds = -5` passed through +configuration silently and only blew up when someone actually ran +`./gradlew affectedTest`. IDE sync, `./gradlew help`, and +`./gradlew tasks` all ran green against a misconfigured build. + +v2.1 adds a mirror check in `AffectedTestsPlugin#apply` via +`project.afterEvaluate`, so the same error now fires at configuration +completion. IDE sync and any dry Gradle invocation now surface the +misconfiguration immediately. The builder-side check stays in place as +belt-and-braces for programmatic callers that bypass the DSL extension. + +Regression coverage: +`AffectedTestsPluginTest.negativeGradlewTimeoutFailsAtConfigurationTime` +walks the thrown exception chain end-to-end and pins the knob name, the +rejected value, and the `>= 0` range bound in the error message. + ### Removed — v2.0 breaking release: legacy v1 knobs are gone The three v1 configuration knobs that were deprecated across the v1.9.x diff --git a/README.md b/README.md index 78f9263..3e9e919 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,11 @@ affectedTests { // output is not available when a timeout is set — leave at 0 and // enforce the deadline at the CI-job level if you rely on scan // ingestion of child-process output. + // + // Negative values are rejected. Since v2.1 the range check fires at + // configuration time (IDE sync / `./gradlew help`) rather than only + // when the task executes, so a typo like `-5` is caught without + // needing to actually launch `affectedTest`. gradlewTimeoutSeconds = 1800 // 30 min; use 3600 for suites with integration tests // ---------------- Discovery tuning ---------------- @@ -340,12 +345,23 @@ The `onUnmappedFile = "full_suite"` default follows the "run more, never run les ### Migrating from v1 config -**v2.0.0 removed the three v1 legacy knobs.** If any of these still appear in your `build.gradle`, Gradle configuration will fail with an unknown-property error before the `affectedTest` task runs: +**v2.0.0 removed the three v1 legacy knobs.** If any of these still appear in your `build.gradle`, Gradle configuration will fail before the `affectedTest` task runs: - `runAllIfNoMatches` - `runAllOnNonJavaChange` - `excludePaths` +Since **v2.1.0**, the error is targeted: instead of Gradle's generic "unknown property" message, you'll see a migration hint naming the exact v2 replacement knob. For example, `runAllIfNoMatches = false` now fails with: + +``` +> affectedTests.runAllIfNoMatches was removed in v2.0.0. Use + onEmptyDiff = "full_suite" and/or onDiscoveryEmpty = "full_suite" + instead (or set mode = "ci" / "strict" to get those defaults). See + CHANGELOG.md v2.0 for the full migration table. +``` + +Shims exist for all three legacy knobs; Kotlin DSL callers already get a compile error naming the removed property. + #### Deprecation timeline | Release | What happens | @@ -482,6 +498,7 @@ Versions are managed automatically via [axion-release](https://github.com/allegr | Check what version this branch is | `./gradlew currentVersion` | | Auto patch release (e.g. `1.9.12` → `1.9.13`) | Merge to `master` — the release workflow does the rest | | Force a minor or major release (e.g. `1.9.x` → `1.10.0`) | GitHub → Actions → **Release** → *Run workflow* → fill `version` (e.g. `1.10.0`), or run `gh workflow run release.yml --ref master -f version=1.10.0` | +| Pin a minor/major on a **merge-to-master** release (no dispatch) | Commit a `.release-version` file at repo root containing the SemVer (e.g. `2.1.0`) in the same PR whose merge should ship that version. The release workflow tags that version, publishes it, then auto-deletes the file in a `[skip ci]` follow-up commit so the next push falls back to auto-patch-increment. | | Release-candidate / pre-release | Trigger *Run workflow* with `version: 1.10.0-RC1`, or locally: `./gradlew release -Prelease.versionIncrementer=incrementPrerelease` | | Manually re-run a failed publish | Re-trigger the workflow on the already-tagged commit — portal-check + release-check steps are idempotent | 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 df76d46..038e62c 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 @@ -1,5 +1,8 @@ package io.affectedtests.gradle; +import java.util.List; + +import org.gradle.api.GradleException; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; @@ -244,10 +247,82 @@ public abstract class AffectedTestsExtension { * *

Recommended values: {@code 1800} (30 min) for merge-gate * unit-test runs, {@code 3600} (1 hour) for suites that include - * integration tests. Must be {@code >= 0}; the core config - * builder rejects negative values at build-config time. + * integration tests. Must be {@code >= 0}; the plugin rejects + * negative values at {@code afterEvaluate} (configuration) time + * and again at task-execution time as belt-and-braces. * * @return the gradlew timeout property in seconds */ public abstract Property getGradlewTimeoutSeconds(); + + // ------------------------------------------------------------------ + // v2 migration hints for the three legacy knobs removed in v2.0. + // + // The properties themselves are gone, so Gradle's default behaviour + // for `affectedTests.runAllIfNoMatches = false` is a terse + // "Could not set unknown property 'runAllIfNoMatches'" — correct + // but unhelpful for operators reading a v1 build.gradle. + // + // These shim setters are invoked by Groovy DSL assignment + // (`ext.foo = bar` maps to `setFoo(bar)` on the managed type) and + // replace the terse error with an actionable v2 replacement + // pointing at the exact `onXxx` knob to use. Kotlin DSL callers + // get a compile error referencing the removed property, which is + // already clearer than Groovy's runtime error, so no shim needed + // there. + // + // If v2 ever adds real `runAllIfNoMatches` / `runAllOnNonJavaChange` + // / `excludePaths` semantics back (unlikely — the v2 names are + // strictly richer), delete these shims and re-introduce the + // abstract Property getters. + // ------------------------------------------------------------------ + + /** + * Migration shim: {@code runAllIfNoMatches} was removed in v2.0. + * Intercepts a v1-style assignment in Groovy DSL and raises a + * targeted migration error instead of Gradle's generic + * "unknown property" failure. + * + * @param ignored v1 boolean value (never read) + * @throws GradleException always, with a v2 replacement hint + */ + @SuppressWarnings("unused") + public void setRunAllIfNoMatches(Object ignored) { + throw new GradleException( + "affectedTests.runAllIfNoMatches was removed in v2.0.0. " + + "Use onEmptyDiff = \"full_suite\" and/or " + + "onDiscoveryEmpty = \"full_suite\" instead " + + "(or set mode = \"ci\" / \"strict\" to get those defaults). " + + "See CHANGELOG.md v2.0 for the full migration table."); + } + + /** + * Migration shim: {@code runAllOnNonJavaChange} was removed in v2.0. + * + * @param ignored v1 boolean value (never read) + * @throws GradleException always, with a v2 replacement hint + */ + @SuppressWarnings("unused") + public void setRunAllOnNonJavaChange(Object ignored) { + throw new GradleException( + "affectedTests.runAllOnNonJavaChange was removed in v2.0.0. " + + "Use onUnmappedFile = \"full_suite\" (v1 true) or " + + "onUnmappedFile = \"selected\" (v1 false) instead. " + + "See CHANGELOG.md v2.0 for the full migration table."); + } + + /** + * Migration shim: {@code excludePaths} was removed in v2.0. + * + * @param ignored v1 list value (never read) + * @throws GradleException always, with a v2 replacement hint + */ + @SuppressWarnings("unused") + public void setExcludePaths(List ignored) { + throw new GradleException( + "affectedTests.excludePaths was removed in v2.0.0. " + + "Use ignorePaths (glob patterns that bypass test selection) " + + "or outOfScopeTestDirs (test dirs the affectedTest task never dispatches) " + + "depending on intent. See CHANGELOG.md v2.0 for the full migration table."); + } } 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 1e80a64..48442cf 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 @@ -1,5 +1,6 @@ package io.affectedtests.gradle; +import org.gradle.api.GradleException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.file.Directory; @@ -88,6 +89,21 @@ public void apply(Project project) { rootProject.allprojects(p -> p.getPluginManager().withPlugin("java", unused -> task.dependsOn(p.getTasks().named("testClasses")))); }); + + // Validate scalar-range constraints at configuration completion so + // operators get feedback during IDE sync / a dry `./gradlew help` + // run instead of having to execute the task to see a negative + // timeout get rejected. The task-side builder keeps its own + // range check as belt-and-braces for programmatic callers that + // bypass the extension. + project.afterEvaluate(p -> { + Long timeout = extension.getGradlewTimeoutSeconds().getOrNull(); + if (timeout != null && timeout < 0L) { + throw new GradleException( + "affectedTests.gradlewTimeoutSeconds must be >= 0 " + + "(0 disables the timeout); got " + timeout); + } + }); } /** 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 b85e672..b9fd2be 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 @@ -1,10 +1,14 @@ package io.affectedtests.gradle; import io.affectedtests.core.config.AffectedTestsConfig; +import org.gradle.api.GradleException; import org.gradle.api.Project; +import org.gradle.api.internal.project.ProjectInternal; import org.gradle.testfixtures.ProjectBuilder; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class AffectedTestsPluginTest { @@ -192,6 +196,103 @@ void legacyDslKnobsNoLongerExistInV2() { "config builder"); } + @Test + void legacyKnobAssignmentThrowsWithV2MigrationHint_runAllIfNoMatches() { + // S10 polish (v2.1): a v1 user dropping `runAllIfNoMatches = false` + // into their build.gradle used to hit Gradle's generic + // "unknown property" error, which names the knob but offers no + // v2 replacement. The shim setter should replace that with a + // targeted message pointing at the exact onXxx knob that took + // over each responsibility, so the v1 build.gradle becomes + // fixable without grepping the CHANGELOG. + AffectedTestsExtension ext = freshExtension(); + + GradleException ex = assertThrows(GradleException.class, + () -> ext.setRunAllIfNoMatches(false)); + assertTrue(ex.getMessage().contains("runAllIfNoMatches"), + "Error must name the removed v1 knob so grep-based alerting still locates it"); + assertTrue(ex.getMessage().contains("onEmptyDiff") + && ex.getMessage().contains("onDiscoveryEmpty"), + "Error must name BOTH v2 replacement knobs — v1's single boolean split into two per-situation actions in v2"); + assertTrue(ex.getMessage().contains("v2.0") || ex.getMessage().contains("CHANGELOG"), + "Error must point at the CHANGELOG/v2 migration doc"); + } + + @Test + void legacyKnobAssignmentThrowsWithV2MigrationHint_runAllOnNonJavaChange() { + AffectedTestsExtension ext = freshExtension(); + + GradleException ex = assertThrows(GradleException.class, + () -> ext.setRunAllOnNonJavaChange(true)); + assertTrue(ex.getMessage().contains("runAllOnNonJavaChange")); + assertTrue(ex.getMessage().contains("onUnmappedFile"), + "Error must name the direct v2 replacement so the fix is mechanical"); + } + + @Test + void legacyKnobAssignmentThrowsWithV2MigrationHint_excludePaths() { + AffectedTestsExtension ext = freshExtension(); + + GradleException ex = assertThrows(GradleException.class, + () -> ext.setExcludePaths(List.of("**/build/**"))); + assertTrue(ex.getMessage().contains("excludePaths")); + // excludePaths is the only knob whose v1 semantics straddled two + // v2 knobs — ignorePaths (never influences selection) vs + // outOfScopeTestDirs (test dirs the affectedTest task won't + // dispatch). The migration hint must name both so the operator + // can pick the right one rather than guessing. + assertTrue(ex.getMessage().contains("ignorePaths")); + assertTrue(ex.getMessage().contains("outOfScopeTestDirs")); + } + + @Test + void negativeGradlewTimeoutFailsAtConfigurationTime() { + // S11 polish (v2.1): the range check used to live in the task + // builder so an invalid value only blew up when someone actually + // ran `./gradlew affectedTest`. Move it into the plugin's + // afterEvaluate so IDE sync and `./gradlew help` surface the + // misconfiguration immediately. Builder-side check stays as + // belt-and-braces and is covered by + // AffectedTestsConfigTest#builderRejectsNegativeGradlewTimeout. + Project project = ProjectBuilder.builder().build(); + project.getPlugins().apply("io.github.vedanthvdev.affectedtests"); + + AffectedTestsExtension ext = project.getExtensions() + .getByType(AffectedTestsExtension.class); + ext.getGradlewTimeoutSeconds().set(-5L); + + GradleException ex = assertThrows(GradleException.class, + () -> ((ProjectInternal) project).evaluate()); + // ProjectInternal#evaluate wraps afterEvaluate failures in a + // ProjectConfigurationException with a generic "A problem + // occurred evaluating..." header. The real validation message + // lives on the cause — walk the chain. + String message = collectMessages(ex); + assertTrue(message.contains("gradlewTimeoutSeconds"), + "Error must name the misconfigured knob (saw: " + message + ")"); + assertTrue(message.contains("-5"), + "Error must echo the rejected value so the operator can locate it in build.gradle (saw: " + message + ")"); + assertTrue(message.contains(">= 0"), + "Error must state the valid range — bare rejection without a bound is a support ticket (saw: " + message + ")"); + } + + private static String collectMessages(Throwable t) { + StringBuilder sb = new StringBuilder(); + Throwable current = t; + while (current != null) { + if (sb.length() > 0) sb.append(" || "); + sb.append(current.getMessage()); + current = current.getCause(); + } + return sb.toString(); + } + + private static AffectedTestsExtension freshExtension() { + Project project = ProjectBuilder.builder().build(); + project.getPlugins().apply("io.github.vedanthvdev.affectedtests"); + return project.getExtensions().getByType(AffectedTestsExtension.class); + } + private static void assertAllAbsent(Class type, java.util.List forbiddenNames, String surfaceLabel) {