diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 00000000..aef4dc6f --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,66 @@ +# CI for the Gradle plugin (refactor-first-gradle-plugin), which is excluded from the +# Maven reactor and built with its own Gradle wrapper. +name: Gradle Plugin CI + +on: + push: + branches: [ main ] + paths: + - 'refactor-first-gradle-plugin/**' + - 'report/**' + - '.github/workflows/gradle.yml' + pull_request: + branches: [ main ] + paths: + - 'refactor-first-gradle-plugin/**' + - 'report/**' + - '.github/workflows/gradle.yml' + +jobs: + build: + strategy: + matrix: + os: [ ubuntu-latest, windows-latest ] + java-version: [ 17, 25 ] + + runs-on: ${{ matrix.os }} + + defaults: + run: + shell: bash + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'zulu' + cache: maven + + # setup-gradle caches the Gradle user home and validates any wrapper jars + # found in the repository (including refactor-first-gradle-plugin/gradlew). + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + # The plugin consumes the (SNAPSHOT during development) report module and its + # transitives as real jars via mavenLocal(); install them first. + - name: Install report module to Maven local + run: mvn -B -q install -DskipTests -pl report -am + + # Guard against version drift: gradle.properties must track the Maven project version. + - name: Check gradle.properties version matches Maven project version + run: | + mvnVersion=$(grep -m1 '' pom.xml | sed -E 's:\s*::g') + gradleVersion=$(grep '^refactorFirstVersion=' refactor-first-gradle-plugin/gradle.properties | cut -d= -f2-) + echo "Maven project.version=$mvnVersion, gradle.properties refactorFirstVersion=$gradleVersion" + if [ "$mvnVersion" != "$gradleVersion" ]; then + echo "FATAL: version mismatch between pom.xml and refactor-first-gradle-plugin/gradle.properties" + exit 1 + fi + + - name: Build and test the Gradle plugin + working-directory: refactor-first-gradle-plugin + run: ./gradlew clean build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee8cbae..2d624a6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,15 @@ jobs: distribution: 'temurin' cache: maven - name: Set release version - run: mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.version }} + # Gradle plugin version sync: refactor-first-gradle-plugin/gradle.properties + # (refactorFirstVersion) must be bumped to the same version in the commits + # produced by this workflow. The Gradle plugin is published separately + # (./gradlew publishPlugins, maintainer-only) and must only be published + # AFTER this workflow has landed the matching `report` artifact on Maven + # Central — the plugin jar is thin and resolves `report` at runtime. + run: | + mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.version }} + sed -i "s/^refactorFirstVersion=.*/refactorFirstVersion=${{ github.event.inputs.version }}/" refactor-first-gradle-plugin/gradle.properties - name: Commit & Push changes uses: actions-js/push@master @@ -72,7 +80,9 @@ jobs: JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_RELEASE_DEPLOY_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD }} - name: Set release version - run: mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.nextVersion }}-SNAPSHOT + run: | + mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.nextVersion }}-SNAPSHOT + sed -i "s/^refactorFirstVersion=.*/refactorFirstVersion=${{ github.event.inputs.nextVersion }}-SNAPSHOT/" refactor-first-gradle-plugin/gradle.properties - name: Commit & Push changes uses: actions-js/push@master diff --git a/AGENTS.md b/AGENTS.md index 98089b15..b8265328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,9 @@ Constraints: 25 with no `--add-exports` workaround (older Spotless 2.x / palantir-java-format builds failed on JDK 21+ without javac exports and could not run on JDK 25 at all). -- CI (`maven.yml`, `maven-pr.yml`) builds a JDK 17 + JDK 25 matrix. +- CI (`maven.yml`, `maven-pr.yml`) builds a JDK 17 + JDK 25 matrix; the Gradle + plugin has its own workflow (`gradle.yml`) on the same JDK matrix plus + Windows. - `CostBenefitCalculatorTest.testCostBenefitCalculation` runs on all JDKs; its former `@DisabledOnJre(JRE.JAVA_25)` (OpenRewrite 8.90.4 fixture failure, issue #8712 family) no longer reproduces now that @@ -114,6 +116,45 @@ back to returning the Java-only DTO with a `log.warn` (`"Kotlin analysis failed; falling back to Java-only graph"`). This fallback is for build failures, not for "Kotlin is absent". +## Gradle plugin + +`refactor-first-gradle-plugin/` is **excluded from the Maven reactor** and built +by its own Gradle wrapper (Gradle 9.8.0 — 9.8+ required: the plugin classpath +carries the class-file-69 `rewrite-java-25` jar, whose bytecode Gradle ≤ 9.0 +cannot instrument): + +- Build & test: `cd refactor-first-gradle-plugin && ./gradlew clean build` +- Plugin id: `org.hjug.refactorfirst`; tasks `refactorFirstHtmlReport`, + `refactorFirstSimpleHtmlReport`, `refactorFirstCsvReport`, + `refactorFirstJsonReport` (task/output contract: see + `plans/finish-pr-157-v2.md` §1; verified by `RefactorFirstPluginTest` + [ProjectBuilder] and `RefactorFirstPluginFunctionalTest` [TestKit]). +- **Thin jar — no shadow plugin, no `minimizeJar`.** The plugin resolves + `org.hjug.refactorfirst.report:report` and its transitives as real jars, so + `codebase-graph-builder`'s JEP 238 `META-INF/versions/25` entries stay intact + (shading with minimize would strip them and break Java 25 parsing). +- **No `getProject()` in `@TaskAction`** (Gradle 9 / configuration-cache + hygiene); values are captured at registration time. Tasks declare no + `@Input`/`@Output` and `getOutputs().upToDateWhen(t -> false)` — they always + run (guarded by `configurationCacheRunHasNoProblems` and `tasksRunEveryTime`). +- `refactorFirstJsonReport` must invoke + `org.hjug.refactorfirst.report.JsonGenerator` (the Maven plugin's generator), + **never** `org.hjug.refactorfirst.report.json.JsonReportExecutor`. +- Version sync: `refactor-first-gradle-plugin/gradle.properties` + `refactorFirstVersion` must equal the Maven `project.version`; `gradle.yml` + fails the build on drift, and `release.yml` bumps both in the same commit. + Release order: `report` et al. must be on Maven Central **before** + `./gradlew publishPlugins` (maintainer-only) publishes the thin plugin jar. +- Local dev: run `mvn install` (or `-pl report -am`) first so `mavenLocal()` + can resolve the SNAPSHOT `report` dependency, **then** `./gradlew + publishToMavenLocal` so consuming builds can resolve the plugin marker + `org.hjug.refactorfirst:org.hjug.refactorfirst.gradle.plugin` from + `mavenLocal()`. Consumers need `mavenLocal()` in `pluginManagement + repositories`; never point `resolutionStrategy.eachPlugin.useModule` at the + marker coordinates (that module is POM-only) — either omit + `resolutionStrategy` or point `useModule` at the implementation module + `org.hjug.refactorfirst.plugin:refactor-first-gradle-plugin`. + ## Testing Notes - JUnit 5 with parameterized tests - Test fixtures in `test-resources/src/test/resources` diff --git a/README.md b/README.md index a1cf2c4c..209ed95c 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,45 @@ Specify with -D if running on the command line. e.g. ```-DbackEdgeAnalysisCount The Java 25 parser activates automatically when running on a JDK 25+ runtime (JEP 238 multi-release jar); there is no configuration flag for it. -## But I'm using Gradle / my project layout isn't typical! -I plan to create a Gradle plugin and (possibly) support non-conventional project structures in the future, but in the meantime you can create a dummy POM file in the same directory as your .git directory to show your project's name in the report: +## Using the Gradle Plugin + +Apply the plugin in `build.gradle.kts` (or `build.gradle`): + +```kotlin +plugins { + id("org.hjug.refactorfirst") version "0.11.0" +} +``` + +Tasks (run with `./gradlew ` on the project the plugin is applied to): + +|Task| Output | +|----|----------------------------------------------------------------------| +|`refactorFirstHtmlReport`| `build/reports/refactorfirst/refactor-first-report.html` | +|`refactorFirstSimpleHtmlReport`| `build/reports/refactorfirst/refactor-first-report.html` | +|`refactorFirstCsvReport`| `build/reports/refactorfirst/RefFirst_P_PV_PD.csv` | +|`refactorFirstJsonReport`| `.refactorfirst/refactor-first.json` | + +Reports are always regenerated: the tasks never report `UP-TO-DATE` and are never loaded `FROM-CACHE`. + +The `refactorFirst` extension accepts the same options as the Maven plugin (described above), plus an output override: + +```kotlin +refactorFirst { + showDetails = false + backEdgeAnalysisCount = 50 + analyzeCycles = true + minifyHtml = false + excludeTests = true + testSourceDirectory = "src/test" + projectName = "My Project" // defaults to the Gradle project name + projectVersion = "1.0-SNAPSHOT" // defaults to the Gradle project version + outputDirectory = "build/rf" // HTML/CSV only; the JSON report always writes to .refactorfirst +} +``` + +## But my project layout isn't typical! +If your project layout isn't conventional, you can create a dummy POM file in the same directory as your .git directory to show your project's name in the report: ```xml /build/reports/refactorfirst/` of the project the task is invoked on | +| JSON output | `/.refactorfirst/` | +| JSON implementation | `refactorFirstJsonReport` **must invoke `org.hjug.refactorfirst.report.JsonGenerator`** (the Maven plugin's generator), **not** `org.hjug.refactorfirst.report.json.JsonReportExecutor` | +| Up-to-date/caching behavior | Tasks **always run** when invoked (never `UP-TO-DATE`, never `FROM-CACHE`) | + +Development is TDD: every phase lands a failing test first, then the implementation that turns it green. Commands assume +the plugin's own Gradle wrapper: `cd refactor-first-gradle-plugin && ./gradlew …`. + +## 2. Current state (what PR 157 actually delivered) + +`../refactor-first-gradle-plugin` (excluded from the Maven reactor; built by its own wrapper, Gradle 9.0.0): + +- `RefactorFirstPlugin` — creates extension `refactorFirst`, registers the 4 correctly-named tasks (group + `RefactorFirst`). +- `RefactorFirstExtension` — mirrors Maven plugin options (`showDetails`, `backEdgeAnalysisCount`, `analyzeCycles`, + `minifyHtml`, + `excludeTests`, `testSourceDirectory`, `projectName`, `projectVersion`, + `outputDirectory`). +- `HtmlReportTask`, `SimpleHtmlReportTask`, `CsvReportTask`, `JsonReportTask` + — each pulls the extension in `@TaskAction generate()` via `getProject()` + and calls the report module (`HtmlReport`/`SimpleHtmlReport` 10-arg + `execute`, `CsvReport.execute`, `JsonReportExecutor.execute`). + +### Gaps vs. the contract + +1. **Plugin id is `org.hjug.refactor-first`**, not `org.hjug.refactorfirst`. +2. **Default output is `target/site` via `RefactorFirstExtension. + resolveOutputDir`** — not `build/reports/refactorfirst` / `.refactorfirst`. +3. **`RefactorFirstPlugin.relativizeToProject()` converts the output dir into a project-relative string**, which the + report layer then resolves against the *process working directory* (a Devin review finding on the PR). In a Gradle + daemon this misplaces or escapes reports. The current report layer accepts absolute paths, so the helper must be + deleted, not fixed. +4. **`JsonReportTask` must invoke `JsonGenerator`, not `JsonReportExecutor`.** + PR 157 wired the task to `JsonReportExecutor`, which writes + `refactor-first-data.json` into an arbitrary directory and leaks a + `CostBenefitCalculator`/JGit handle per run (a Devin review finding, poisonous in a long-lived Gradle daemon). The + task must instead call + `org.hjug.refactorfirst.report.JsonGenerator` — the same class the Maven plugin's `jsonReport` mojo uses — which + already writes exactly + `/.refactorfirst/refactor-first.json` plus the bundled viewer when passed `outputDir == null`. + `JsonReportTask` must not reference + `JsonReportExecutor` after this change (verify by grep / code review, and by T7's assertion set). +5. **No tests at all.** +6. **Build plumbing is stale:** hardcoded `version = '0.7.2-SNAPSHOT'` and + `implementation 'org.hjug.refactorfirst.report:report:0.7.2-SNAPSHOT'` + (main line is `0.11.0-SNAPSHOT`); toolchain is Java 11 (project baseline is 17; Gradle 9 requires a 17+ JVM anyway); + a `maven { url 'target/dependencies' }` + repo hack with a 400+ file local cache dump sitting in `target/dependencies/`; + `shadowJar { minimizeJar = true }` (which would strip `META-INF/versions/25` + and break JEP 238 Java 25 parsing — and caused the PR author's unexplained runtime errors); `gradlew.bat` still + passes an empty `-classpath ""` next to + `-jar` (CodeRabbit finding on the PR). + +## 3. Design decisions (made now so phases stay mechanical) + +1. **Publish a thin jar.** `java-gradle-plugin` + `maven-publish` (+ optional + `com.gradle.plugin-publish`) produce normal jars with a POM; Gradle resolves `report` and its transitives as real + jars. This removes the need for `shadowJar`/`minimizeJar` entirely (deleting both), and preserves the JEP 238 + multi-release activation of the Java 25 parser for free: plugin classloaders load `codebase-graph-builder-*.jar` + (with its + `Multi-Release: true` manifest) as a real jar from the dependency cache. **Do not shade.** Keep + `apply plugin: 'com.gradleup.shadow'` removed. +2. **Absolute output paths, no relativization.** Each task computes an absolute `File` for its output directory at + configuration time and passes its absolute path to the existing report APIs (they accept absolute path strings; + `ReportWriter.writeReportToDisk` normalizes via + `Path.of(...).toAbsolutePath()`). Delete `relativizeToProject`. +3. **Defaults:** HTML/SimpleHTML/CSV default output is + `layout.buildDirectory.dir("reports/refactorfirst")`; JSON has no configurable output and always goes to + `/.refactorfirst` via + `JsonGenerator.execute(..., baseDir, null)`. Extension `outputDirectory` + (when set) overrides the HTML/SimpleHTML/CSV default only. +4. **Always run:** tasks declare no `@Input`/`@Output` (so Gradle has nothing to compare) **and** each task constructor + calls + `getOutputs().upToDateWhen(t -> false)` as an explicit guard; none of the tasks are annotated `@CacheableTask`. +5. **Configuration-cache / Gradle 9 hygiene:** no `getProject()` inside + `@TaskAction`. Values are captured at registration time into task fields (extension reference resolved in `apply`, + `projectDir` via + `project.getLayout().getProjectDirectory()`). The extension object is read eagerly at execution (users set it in + `build.gradle(.kts)` before execution; acceptable because no `@Input`s are declared — see 4). +6. **Version sync with Maven:** `gradle.properties` carries + `refactorFirstVersion=0.11.0-SNAPSHOT`, used for both the plugin's version and the `report` dependency coordinate. A + CI check greps it against the Maven `project.version` to prevent drift (release builds consume the published + non-SNAPSHOT `report` from Maven Central; local dev uses + `mavenLocal()` after `mvn install`). + +## 4. Phases + +Per-phase rule: write the failing test (s), watch them fail for the stated reason, then implement and re-run. Run +`./gradlew test` (and once wired, the functional suite) after every phase. + +### Phase 0 — Repo hygiene & build scaffolding (no behavior change) + +Infra that makes later TDD possible; commit separately. + +- `build.gradle`: + - `version` ← `gradle.properties` `refactorFirstVersion`; dependency → + `implementation "org.hjug.refactorfirst.report:report:$refactorFirstVersion"`. + - Toolchain `11` → `17`. + - Remove `repositories { maven { url 'target/dependencies' } }`; keep + `mavenLocal()` + `mavenCentral()`. + - Remove the `com.gradleup.shadow` plugin, the `shadowJar { minimizeJar }` + block, and the bogus `jar` manifest `Main-Class` attribute (a plugin jar has no main class). Add + `dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.13.3'; + testImplementation 'org.eclipse.jgit:org.eclipse.jgit:7.7.0.202606012155-r'; + testImplementation gradleTestKit() }` and + `tasks.test { useJUnitPlatform() }`. + - Keep `gradlePlugin {}` block; **do not change the id yet** (Phase 1). +- Delete the stale on-disk junk: `../refactor-first-gradle-plugin/target`, + `build/`, `.gradle/` (untracked); ensure root/module `../.gitignore` cover + `.gradle/` and `build/`. +- `gradlew.bat`: drop the empty `-classpath "%CLASSPATH%"` from the + `-jar` invocation line (PR 157 CodeRabbit finding). +- Sanity: `./gradlew help` works on JDK 17 and JDK 25. + +### Phase 1 — Plugin id & task registration (unit tests via `ProjectBuilder`) + +New test: `src/test/java/org/hjug/gradlereport/RefactorFirstPluginTest.java` +(`ProjectBuilder.builder().build()` + +`project.getPluginManager().apply("org.hjug.refactorfirst")`). + +RED tests: + +- T1 `appliesByContractId` — applying `org.hjug.refactorfirst` succeeds. (Fails today: id is `org.hjug.refactor-first`.) +- T2 `registersAllFourTasksWithContractNames` — after apply, + `tasks.getByName(...)` resolves each of the 4 contract names with group + `RefactorFirst` and a non-blank description. +- T3 `tasksAreNeverUpToDate` — for each task, + `!task.getOutputs().getUpToDateSpec().isSatisfiedBy(task)` and no declared inputs/outputs + (`task.getInputs().getHasInputs()` is false). + +GREEN: + +- `build.gradle` → `gradlePlugin { plugins { refactorFirstPlugin { id = + 'org.hjug.refactorfirst' ... } } }`. +- Each task constructor adds + `getOutputs().upToDateWhen(t -> false);` (with a javadoc/comment stating the contract: reports always regenerate). + +### Phase 2 — Output locations & task wiring (functional tests via TestKit) + +New test: `RefactorFirstPluginFunctionalTest.java` (GradleRunner, JUnit 5 `@TempDir`). + +Shared fixture helper in the test: +`newSampleProject(File dir)` — writes `settings.gradle.kts` and +`build.gradle.kts` (`plugins { id("org.hjug.refactorfirst") }`), creates +`src/main/java/com/example/Sample.java`, and uses JGit to `init`, add, and commit (the report layer early-returns +without a `.git` dir). + +RED tests (fail on output location + id today): + +- T4 `htmlReportWritesToBuildReportsRefactorFirst` — running + `refactorFirstHtmlReport` succeeds and + `build/reports/refactorfirst/refactor-first-report.html` exists (assert non-empty). +- T5 `simpleHtmlReportWritesToBuildReportsRefactorFirst` — same path and file name for `refactorFirstSimpleHtmlReport`. +- T6 `csvReportWritesToBuildReportsRefactorFirst` — running + `refactorFirstCsvReport` creates a file matching + `build/reports/refactorfirst/RefFirst_P*_PV*_PD*.csv`. +- T7 `jsonReportProducesJsonGeneratorOutput` — running + `refactorFirstJsonReport` exercises the `JsonGenerator` code path: it creates `../.refactorfirst/refactor-first.json` + **plus** the bundled viewer (`index.html`, `refactor-first-report.mustache` — resources only + `JsonGenerator` copies), the JSON parses as + `RefactorFirstReportDTO` (not the legacy `JsonReport` DTO), and + `refactor-first-data.json` (**only** produced by the former + `JsonReportExecutor` wiring) is **not** created anywhere. +- T8 `noReportsAtLegacyLocations` — none of `target/site`, + `refactor-first-data.json`, or a top-level `reports/` dir are created by any of the 4 tasks. + +GREEN (implement after all five fail): + +- `RefactorFirstExtension`: default output becomes + `build/reports/refactorfirst`; `resolveOutputDir(File)` is removed. +- Plugin `apply()`: capture `DirectoryProperty reportsDir = + project.getLayout().getBuildDirectory().dir("reports/refactorfirst")` and the extension; pass into each task at + registration (task constructor or setters) + — no `getProject()` inside `@TaskAction`. +- `HtmlReportTask` / `SimpleHtmlReportTask`: + `report.execute(backEdgeAnalysisCount, analyzeCycles, showDetails, + minifyHtml, excludeTests, testSourceDirectory, projectName, projectVersion, + projectDirFile, outputDirFile.getAbsolutePath())`. +- `CsvReportTask`: `csvReport.execute(showDetails, projectName, projectVersion, + outputDir.getAbsolutePath(), projectDirFile)`. +- `JsonReportTask`: **replace `JsonReportExecutor` with `JsonGenerator`**: + `new JsonGenerator().execute(backEdgeAnalysisCount, analyzeCycles, + showDetails, excludeTests, testSourceDirectory, projectName, projectVersion, + projectDirFile, null)` (null outputDir ⇒ `/.refactorfirst`). Remove the + `org.hjug.refactorfirst.report.json.JsonReportExecutor` import entirely. This also eliminates the daemon-hostile JGit + handle leak flagged on the PR. +- Delete `RefactorFirstPlugin.relativizeToProject`. + +Notes: + +- `HtmlReport` extends `SimpleHtmlReport`; both write + `refactor-first-report.html`. +- Functional tests must invoke the tasks with `--quiet` and no + `--configuration-cache` flags initially; add a single config-cache run (`--configuration-cache`) at the end asserting + "no problems were found" to guard the no-`getProject()`-at-execution rule. + +### Phase 3 — Always-run contract (functional) + +RED → GREEN (these pin the contract; they may already pass after Phase 1, in which case they are characterization +tests — still required): + +- T9 `tasksRunEveryTime`: for each of the 4 tasks, run it twice (two + `GradleRunner` builds over the same project), assert both outcomes are + `TaskOutcome.SUCCESS` (not `UP_TO_DATE` / `FROM_CACHE`). +- T10 `outputsChangeWhenSourceChanges`: run `refactorFirstJsonReport` twice with a source-file edit + new commit between + runs; assert + `../.refactorfirst/refactor-first.json` mtime/content changes (guards against accidental caching introduced later). + +If T9/T10 fail, the fix lives in the task classes (see Phase 1 step for +`upToDateWhen`), not in test flags. + +### Phase 4 — Extension mapping (unit + functional) + +- T11 `extensionDefaultsMatchContract` (unit): `showDetails=false`, + `backEdgeAnalysisCount=50`, `analyzeCycles=true`, `minifyHtml=false`, + `excludeTests=true`, blank `testSourceDirectory`/`projectName`/ + `projectVersion`, no `outputDirectory` override. +- T12 `extensionOverridesAreHonored` (functional): set + `refactorFirst { outputDirectory = "build/rf"; projectName = "CustomName" }` + in the sample project, run `refactorFirstHtmlReport`, assert output lands in + `build/rf/` and the HTML contains `CustomName`. + +GREEN: route extension values through the (already wired) registration-time capture implemented in Phase 2. + +### Phase 5 — Publishing, CI, docs + +- `com.gradle.plugin-publish` (latest 2.x line) configured with + `website`/`vcsUrl`/tags; `publishPlugins` remains a maintainer-only manual step gated on portal credentials. + Maven-publish remains for Maven Central consumers using `classpath(...)` in `buildscript`. +- CI: new job in `maven.yml` / `maven-pr.yml` (or a dedicated + `gradle.yml`) running `cd refactor-first-gradle-plugin && ./gradlew clean + build` on the JDK 17 + 25 matrix; add the version-sync check (grep `gradle.properties` vs Maven `project.version`). +- `../README.md`: replace the "But I'm using Gradle / dummy POM" section with real instructions + (`plugins { id("org.hjug.refactorfirst") version "…" }`, task list, output locations, extension reference table). +- `../AGENTS.md`: add a short "Gradle plugin" section (module path, build/test commands, thin-jar-no-shadow rule, contract + table pointer) — required by repo convention. +- Release workflow: note that a release must publish `report` et al. to Maven Central **before** the plugin version can + be consumed (thin-jar runtime dependency), and that the `gradle.properties` version is bumped in the same commit that + sets the next development version. + +### Phase 6 — Final verification + +- `./gradlew clean build` green on JDK 17 **and** JDK 25 (Java 25 analysis works through the real multi-release + `codebase-graph-builder` jar; no gradle-side work needed — assert the functional tests pass on 25). +- Windows + Linux (and macOS if available) CI legs. +- Full `mvn clean install` still green from the repo root (nothing in the Maven reactor may change; Phase 2/5 touch only + `refactor-first-gradle-plugin`, docs, and workflows). + +## 5. Explicitly out of scope (with reason) + +- **Fixing `JsonReportExecutor`'s `CostBenefitCalculator` leak** — the Gradle plugin stops using it (Phase 2). The CLI + is a short-lived process where the leak is harmless. If desired, a separate tiny PR can wrap it in try-with-resources. +- **Shading / fat-jar distribution of the plugin** — replaced by thin-jar publishing (§3.1). This also retires the PR + TODO "figure out why the jar is so large" and eliminates the `minimizeJar` risk of stripping + `META-INF/versions/25` (Java 25 parser). +- **The Devin "subproject reports reject valid repositories" finding** — it targeted the report layer as it existed in + the PR; since then the report layer gained multi-module/repository-root support. Re-evaluate only if a Phase 2 + functional test on a multi-project sample reveals it again (otherwise drop). +- **Per-subproject task registration / aggregating multi-module reports** — the contract is "the project where it is + invoked"; users apply the plugin per project. + +## 6. Definition of Done + +1. `build.gradle` declares plugin id `org.hjug.refactorfirst`; T1–T3 prove id, names, and never-up-to-date via + `ProjectBuilder`. +2. TestKit functional tests T4–T10 green on JDK 17 and JDK 25: HTML & Simple HTML produce + `build/reports/refactorfirst/refactor-first-report.html`, CSV produces + `build/reports/refactorfirst/RefFirst_P*_PV*_PD*.csv`, and + `refactorFirstJsonReport` is proven to run `JsonGenerator` (not + `JsonReportExecutor`): `../.refactorfirst/refactor-first.json` + viewer files are produced (T7). Every task reports + `SUCCESS` on repeat execution. +3. No `target/dependencies` repo hack, no shadow/minimize config; the plugin jar is thin; `gradlew.bat` no longer passes + an empty `-classpath`. +4. Docs updated (README + AGENTS.md); CI runs the Gradle build; version-sync check in place. +5. Maven reactor build remains green. diff --git a/pom.xml b/pom.xml index dca923fe..7091fbcd 100644 --- a/pom.xml +++ b/pom.xml @@ -73,10 +73,10 @@ effort-ranker cost-benefit-calculator graph-data-generator + report refactor-first-maven-plugin - + refactor-first-gradle-plugin coverage - report diff --git a/refactor-first-gradle-plugin/build.gradle b/refactor-first-gradle-plugin/build.gradle index d60145a1..35dd7087 100644 --- a/refactor-first-gradle-plugin/build.gradle +++ b/refactor-first-gradle-plugin/build.gradle @@ -2,48 +2,59 @@ plugins { id 'java' id 'java-gradle-plugin' id 'maven-publish' - id 'com.gradleup.shadow' version '9.1.0' + id 'com.gradle.plugin-publish' version '2.2.1' } group = 'org.hjug.refactorfirst.plugin' -version = '0.7.2-SNAPSHOT' +version = refactorFirstVersion java { toolchain { - languageVersion = JavaLanguageVersion.of(11) + languageVersion = JavaLanguageVersion.of(17) } } repositories { mavenLocal() mavenCentral() - maven { url 'target/dependencies' } } dependencies { compileOnly gradleApi() - implementation 'org.hjug.refactorfirst.report:report:0.7.2-SNAPSHOT' + implementation "org.hjug.refactorfirst.report:report:$refactorFirstVersion" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.3' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.eclipse.jgit:org.eclipse.jgit:7.7.0.202606012155-r' + testImplementation gradleTestKit() } jar { manifest { - attributes 'Main-Class': 'org.hjug.gradlereport.RefactorFirstPlugin', - 'Implementation-Title': 'RefactorFirstPlugin', + attributes 'Implementation-Title': 'RefactorFirstPlugin', 'Implementation-Version': version } } -shadowJar { - minimizeJar = true -} - gradlePlugin { + website = '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/refactorfirst/RefactorFirst' + vcsUrl = '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/refactorfirst/RefactorFirst' plugins { refactorFirstPlugin { - id = 'org.hjug.refactor-first' + id = 'org.hjug.refactorfirst' displayName = 'RefactorFirst' description = 'Plugin that identifies God classes in a codebase and suggests which classes should be refactored first.' implementationClass = 'org.hjug.gradlereport.RefactorFirstPlugin' + tags = ['refactoring', 'code-quality', 'static-analysis', 'god-class', 'cyclic-dependencies'] } } } + +// Publishing to the Gradle Plugin Portal is a maintainer-only manual step: +// ./gradlew publishPlugins -Pgradle.publish.key=... -Pgradle.publish.secret=... +// The automatically created 'pluginMaven' publication (java-gradle-plugin + maven-publish) +// remains available for Maven Central consumers using classpath(...) in the buildscript block. + +tasks.test { + useJUnitPlatform() +} diff --git a/refactor-first-gradle-plugin/gradle.properties b/refactor-first-gradle-plugin/gradle.properties new file mode 100644 index 00000000..e79f0a62 --- /dev/null +++ b/refactor-first-gradle-plugin/gradle.properties @@ -0,0 +1 @@ +refactorFirstVersion=0.11.0-SNAPSHOT diff --git a/refactor-first-gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/refactor-first-gradle-plugin/gradle/wrapper/gradle-wrapper.properties index c524e399..299848b1 100644 --- a/refactor-first-gradle-plugin/gradle/wrapper/gradle-wrapper.properties +++ b/refactor-first-gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists \ No newline at end of file +zipStorePath=wrapper/dists diff --git a/refactor-first-gradle-plugin/gradlew b/refactor-first-gradle-plugin/gradlew index 884d84c2..ef07e016 100755 --- a/refactor-first-gradle-plugin/gradlew +++ b/refactor-first-gradle-plugin/gradlew @@ -202,7 +202,7 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, @@ -248,4 +248,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" \ No newline at end of file +exec "$JAVACMD" "$@" diff --git a/refactor-first-gradle-plugin/gradlew.bat b/refactor-first-gradle-plugin/gradlew.bat index 6c3a8229..b4743e35 100644 --- a/refactor-first-gradle-plugin/gradlew.bat +++ b/refactor-first-gradle-plugin/gradlew.bat @@ -36,7 +36,7 @@ set APP_HOME=%DIRNAME% for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -70,11 +70,8 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= - - @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell @@ -91,4 +88,4 @@ exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal -:omega \ No newline at end of file +:omega diff --git a/refactor-first-gradle-plugin/pom.xml b/refactor-first-gradle-plugin/pom.xml index fb513869..982099c0 100644 --- a/refactor-first-gradle-plugin/pom.xml +++ b/refactor-first-gradle-plugin/pom.xml @@ -7,7 +7,7 @@ org.hjug.refactorfirst refactor-first - 0.2.0-SNAPSHOT + 0.11.0-SNAPSHOT org.hjug.refactorfirst.plugin diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/CsvReportTask.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/CsvReportTask.java index 4508de43..77a348a3 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/CsvReportTask.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/CsvReportTask.java @@ -2,30 +2,58 @@ import java.io.File; import org.gradle.api.DefaultTask; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; import org.hjug.refactorfirst.report.CsvReport; +/** + * Generates the RefactorFirst CSV report at + * {@code /build/reports/refactorfirst} (or the extension's {@code outputDirectory}). + */ +@DisableCachingByDefault( + because = "Report tasks always regenerate their output; they are never up-to-date or loaded from cache.") public class CsvReportTask extends DefaultTask { + private RefactorFirstExtension extension; + private File projectDirectory; + private Provider defaultOutputDirectory; + private String defaultProjectName; + private String defaultProjectVersion; + + public CsvReportTask() { + // Contract: reports always regenerate — this task is never UP-TO-DATE and never FROM-CACHE. + getOutputs().upToDateWhen(t -> false); + } + + void configureFrom( + RefactorFirstExtension extension, + File projectDirectory, + Provider defaultOutputDirectory, + String defaultProjectName, + String defaultProjectVersion) { + this.extension = extension; + this.projectDirectory = projectDirectory; + this.defaultOutputDirectory = defaultOutputDirectory; + this.defaultProjectName = defaultProjectName; + this.defaultProjectVersion = defaultProjectVersion; + } + @TaskAction public void generate() { - RefactorFirstExtension ext = getProject().getExtensions().findByType(RefactorFirstExtension.class); - if (ext == null) { - ext = new RefactorFirstExtension(); - } - - final String projectName = ext.getProjectName() != null ? ext.getProjectName() : getProject().getName(); - final String projectVersion = ext.getProjectVersion() != null ? ext.getProjectVersion() : String.valueOf(getProject().getVersion()); - final File baseDir = getProject().getProjectDir(); - final File outputDir = ext.resolveOutputDir(baseDir); + String projectName = isBlank(extension.getProjectName()) ? defaultProjectName : extension.getProjectName(); + String projectVersion = + isBlank(extension.getProjectVersion()) ? defaultProjectVersion : extension.getProjectVersion(); + File outputDir = ExtensionValues.resolveOutputDirectory( + extension, projectDirectory, defaultOutputDirectory.get().getAsFile()); CsvReport csvReport = new CsvReport(); csvReport.execute( - ext.isShowDetails(), - projectName, - projectVersion, - RefactorFirstPlugin.relativizeToProject(baseDir, outputDir), - baseDir - ); + extension.isShowDetails(), projectName, projectVersion, outputDir.getAbsolutePath(), projectDirectory); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/ExtensionValues.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/ExtensionValues.java new file mode 100644 index 00000000..03834cf0 --- /dev/null +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/ExtensionValues.java @@ -0,0 +1,25 @@ +package org.hjug.gradlereport; + +import java.io.File; + +/** Shared resolution of extension values into absolute task inputs. */ +final class ExtensionValues { + + private ExtensionValues() {} + + /** + * Resolves the output directory for the HTML / simple HTML / CSV reports to an absolute path. + * The extension's {@code outputDirectory} overrides the default + * ({@code /build/reports/refactorfirst}); relative overrides resolve against the + * project directory. Absolute paths are passed to the report layer untouched — it normalizes + * via {@code Path.of(...).toAbsolutePath()}. + */ + static File resolveOutputDirectory(RefactorFirstExtension extension, File projectDirectory, File defaultDir) { + String configured = extension.getOutputDirectory(); + if (configured == null || configured.isBlank()) { + return defaultDir; + } + File dir = new File(configured); + return dir.isAbsolute() ? dir : new File(projectDirectory, configured); + } +} diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/HtmlReportTask.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/HtmlReportTask.java index 10511136..6d397a5a 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/HtmlReportTask.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/HtmlReportTask.java @@ -2,35 +2,67 @@ import java.io.File; import org.gradle.api.DefaultTask; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; import org.hjug.refactorfirst.report.HtmlReport; +/** + * Generates the full RefactorFirst HTML report at + * {@code /build/reports/refactorfirst} (or the extension's {@code outputDirectory}). + */ +@DisableCachingByDefault( + because = "Report tasks always regenerate their output; they are never up-to-date or loaded from cache.") public class HtmlReportTask extends DefaultTask { + private RefactorFirstExtension extension; + private File projectDirectory; + private Provider defaultOutputDirectory; + private String defaultProjectName; + private String defaultProjectVersion; + + public HtmlReportTask() { + // Contract: reports always regenerate — this task is never UP-TO-DATE and never FROM-CACHE. + getOutputs().upToDateWhen(t -> false); + } + + void configureFrom( + RefactorFirstExtension extension, + File projectDirectory, + Provider defaultOutputDirectory, + String defaultProjectName, + String defaultProjectVersion) { + this.extension = extension; + this.projectDirectory = projectDirectory; + this.defaultOutputDirectory = defaultOutputDirectory; + this.defaultProjectName = defaultProjectName; + this.defaultProjectVersion = defaultProjectVersion; + } + @TaskAction public void generate() { - RefactorFirstExtension ext = getProject().getExtensions().findByType(RefactorFirstExtension.class); - if (ext == null) { - ext = new RefactorFirstExtension(); - } - - final String projectName = ext.getProjectName() != null ? ext.getProjectName() : getProject().getName(); - final String projectVersion = ext.getProjectVersion() != null ? ext.getProjectVersion() : String.valueOf(getProject().getVersion()); - final File baseDir = getProject().getProjectDir(); - final File outputDir = ext.resolveOutputDir(baseDir); + String projectName = isBlank(extension.getProjectName()) ? defaultProjectName : extension.getProjectName(); + String projectVersion = + isBlank(extension.getProjectVersion()) ? defaultProjectVersion : extension.getProjectVersion(); + File outputDir = ExtensionValues.resolveOutputDirectory( + extension, projectDirectory, defaultOutputDirectory.get().getAsFile()); HtmlReport htmlReport = new HtmlReport(); htmlReport.execute( - ext.getBackEdgeAnalysisCount(), - ext.isAnalyzeCycles(), - ext.isShowDetails(), - ext.isMinifyHtml(), - ext.isExcludeTests(), - ext.getTestSourceDirectory(), + extension.getBackEdgeAnalysisCount(), + extension.isAnalyzeCycles(), + extension.isShowDetails(), + extension.isMinifyHtml(), + extension.isExcludeTests(), + extension.getTestSourceDirectory(), projectName, projectVersion, - baseDir, - RefactorFirstPlugin.relativizeToProject(baseDir, outputDir) - ); + projectDirectory, + outputDir.getAbsolutePath()); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/JsonReportTask.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/JsonReportTask.java index b5e8fc8d..88346ed7 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/JsonReportTask.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/JsonReportTask.java @@ -3,21 +3,61 @@ import java.io.File; import org.gradle.api.DefaultTask; import org.gradle.api.tasks.TaskAction; -import org.hjug.refactorfirst.report.json.JsonReportExecutor; +import org.gradle.work.DisableCachingByDefault; +import org.hjug.refactorfirst.report.JsonGenerator; +/** + * Generates the RefactorFirst JSON report via {@link JsonGenerator} — the same generator the + * Maven plugin's {@code jsonReport} mojo uses. Output always lands in + * {@code /.refactorfirst} (JSON plus the bundled viewer); + * the extension's {@code outputDirectory} does not apply to this task. + */ +@DisableCachingByDefault( + because = "Report tasks always regenerate their output; they are never up-to-date or loaded from cache.") public class JsonReportTask extends DefaultTask { + private RefactorFirstExtension extension; + private File projectDirectory; + private String defaultProjectName; + private String defaultProjectVersion; + + public JsonReportTask() { + // Contract: reports always regenerate — this task is never UP-TO-DATE and never FROM-CACHE. + getOutputs().upToDateWhen(t -> false); + } + + void configureFrom( + RefactorFirstExtension extension, + File projectDirectory, + String defaultProjectName, + String defaultProjectVersion) { + this.extension = extension; + this.projectDirectory = projectDirectory; + this.defaultProjectName = defaultProjectName; + this.defaultProjectVersion = defaultProjectVersion; + } + @TaskAction public void generate() { - RefactorFirstExtension ext = getProject().getExtensions().findByType(RefactorFirstExtension.class); - if (ext == null) { - ext = new RefactorFirstExtension(); - } + String projectName = isBlank(extension.getProjectName()) ? defaultProjectName : extension.getProjectName(); + String projectVersion = + isBlank(extension.getProjectVersion()) ? defaultProjectVersion : extension.getProjectVersion(); - final File baseDir = getProject().getProjectDir(); - final File outputDir = ext.resolveOutputDir(baseDir); + // A null output directory makes JsonGenerator write to /.refactorfirst. + JsonGenerator jsonGenerator = new JsonGenerator(); + jsonGenerator.execute( + extension.getBackEdgeAnalysisCount(), + extension.isAnalyzeCycles(), + extension.isShowDetails(), + extension.isExcludeTests(), + extension.getTestSourceDirectory(), + projectName, + projectVersion, + projectDirectory, + null); + } - JsonReportExecutor jsonReportExecutor = new JsonReportExecutor(); - jsonReportExecutor.execute(baseDir, RefactorFirstPlugin.relativizeToProject(baseDir, outputDir)); + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstExtension.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstExtension.java index ce8d9bce..16d03ccc 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstExtension.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstExtension.java @@ -1,7 +1,5 @@ package org.hjug.gradlereport; -import java.io.File; - /** * Gradle extension to configure RefactorFirst plugin.
* Mirrors the Maven plugin parameters where possible. @@ -19,14 +17,15 @@ public class RefactorFirstExtension { */ private String testSourceDirectory; // e.g. "src/test" - private String projectName; // default to Gradle project name if null + private String projectName; // default to Gradle project name if null private String projectVersion; // default to Gradle project version if null /** - * Output directory relative to project root for generated site files. - * Defaults to target/site (to match Maven plugin output paths). + * Output directory for the HTML / simple HTML / CSV reports, relative to the project + * directory or absolute. Defaults to {@code /build/reports/refactorfirst}. + * Does not apply to the JSON report, which always writes to {@code /.refactorfirst}. */ - private String outputDirectory; // e.g. "target/site" + private String outputDirectory; // e.g. "build/rf" public boolean isShowDetails() { return showDetails; @@ -99,13 +98,4 @@ public String getOutputDirectory() { public void setOutputDirectory(String outputDirectory) { this.outputDirectory = outputDirectory; } - - // Convenience to resolve output directory to an absolute File - File resolveOutputDir(File projectDir) { - String out = outputDirectory; - if (out == null || out.trim().isEmpty()) { - out = "target/site"; // match Maven default location - } - return new File(projectDir, out); - } } diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstPlugin.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstPlugin.java index f605aa6c..4882d1a7 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstPlugin.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/RefactorFirstPlugin.java @@ -3,6 +3,8 @@ import java.io.File; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; /** * The gradle refactor first plugin @@ -12,42 +14,44 @@ public class RefactorFirstPlugin implements Plugin { @Override public void apply(Project project) { // Create extension to configure the plugin - project.getExtensions().create("refactorFirst", RefactorFirstExtension.class); + RefactorFirstExtension extension = + project.getExtensions().create("refactorFirst", RefactorFirstExtension.class); + + // Capture configuration-cache-safe values at registration time: + // no getProject() calls are allowed inside @TaskAction methods. + File projectDir = project.getLayout().getProjectDirectory().getAsFile(); + Provider defaultReportsDir = + project.getLayout().getBuildDirectory().dir("reports/refactorfirst"); + String defaultProjectName = project.getName(); // Register tasks + // The project version is read lazily inside each configuration action: apply() runs while the + // plugins {} block is being evaluated, so getVersion() would still return "unspecified" there. project.getTasks().register("refactorFirstHtmlReport", HtmlReportTask.class, task -> { task.setGroup("RefactorFirst"); task.setDescription("Generates the RefactorFirst full HTML report (with graphs)"); + task.configureFrom( + extension, projectDir, defaultReportsDir, defaultProjectName, String.valueOf(project.getVersion())); }); project.getTasks().register("refactorFirstSimpleHtmlReport", SimpleHtmlReportTask.class, task -> { task.setGroup("RefactorFirst"); task.setDescription("Generates the RefactorFirst simplified HTML report (no heavy graphs)"); + task.configureFrom( + extension, projectDir, defaultReportsDir, defaultProjectName, String.valueOf(project.getVersion())); }); project.getTasks().register("refactorFirstJsonReport", JsonReportTask.class, task -> { task.setGroup("RefactorFirst"); task.setDescription("Generates the RefactorFirst JSON data report"); + task.configureFrom(extension, projectDir, defaultProjectName, String.valueOf(project.getVersion())); }); project.getTasks().register("refactorFirstCsvReport", CsvReportTask.class, task -> { task.setGroup("RefactorFirst"); task.setDescription("Generates the RefactorFirst CSV report"); + task.configureFrom( + extension, projectDir, defaultReportsDir, defaultProjectName, String.valueOf(project.getVersion())); }); } - - public static String relativizeToProject(File baseDir, File outputDir) { - // Maven plugin passes a path relative to project base dir - String basePath = baseDir.getAbsolutePath(); - String outPath = outputDir.getAbsolutePath(); - if (outPath.startsWith(basePath)) { - String rel = outPath.substring(basePath.length()); - if (rel.startsWith(File.separator)) { - rel = rel.substring(1); - } - return rel; - } - // Fallback to default - return "target/site"; - } -} \ No newline at end of file +} diff --git a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/SimpleHtmlReportTask.java b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/SimpleHtmlReportTask.java index 97ad9aee..acef67ba 100644 --- a/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/SimpleHtmlReportTask.java +++ b/refactor-first-gradle-plugin/src/main/java/org/hjug/gradlereport/SimpleHtmlReportTask.java @@ -2,35 +2,67 @@ import java.io.File; import org.gradle.api.DefaultTask; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; import org.hjug.refactorfirst.report.SimpleHtmlReport; +/** + * Generates the simplified RefactorFirst HTML report at + * {@code /build/reports/refactorfirst} (or the extension's {@code outputDirectory}). + */ +@DisableCachingByDefault( + because = "Report tasks always regenerate their output; they are never up-to-date or loaded from cache.") public class SimpleHtmlReportTask extends DefaultTask { + private RefactorFirstExtension extension; + private File projectDirectory; + private Provider defaultOutputDirectory; + private String defaultProjectName; + private String defaultProjectVersion; + + public SimpleHtmlReportTask() { + // Contract: reports always regenerate — this task is never UP-TO-DATE and never FROM-CACHE. + getOutputs().upToDateWhen(t -> false); + } + + void configureFrom( + RefactorFirstExtension extension, + File projectDirectory, + Provider defaultOutputDirectory, + String defaultProjectName, + String defaultProjectVersion) { + this.extension = extension; + this.projectDirectory = projectDirectory; + this.defaultOutputDirectory = defaultOutputDirectory; + this.defaultProjectName = defaultProjectName; + this.defaultProjectVersion = defaultProjectVersion; + } + @TaskAction public void generate() { - RefactorFirstExtension ext = getProject().getExtensions().findByType(RefactorFirstExtension.class); - if (ext == null) { - ext = new RefactorFirstExtension(); - } - - final String projectName = ext.getProjectName() != null ? ext.getProjectName() : getProject().getName(); - final String projectVersion = ext.getProjectVersion() != null ? ext.getProjectVersion() : String.valueOf(getProject().getVersion()); - final File baseDir = getProject().getProjectDir(); - final File outputDir = ext.resolveOutputDir(baseDir); + String projectName = isBlank(extension.getProjectName()) ? defaultProjectName : extension.getProjectName(); + String projectVersion = + isBlank(extension.getProjectVersion()) ? defaultProjectVersion : extension.getProjectVersion(); + File outputDir = ExtensionValues.resolveOutputDirectory( + extension, projectDirectory, defaultOutputDirectory.get().getAsFile()); SimpleHtmlReport htmlReport = new SimpleHtmlReport(); htmlReport.execute( - ext.getBackEdgeAnalysisCount(), - ext.isAnalyzeCycles(), - ext.isShowDetails(), - ext.isMinifyHtml(), - ext.isExcludeTests(), - ext.getTestSourceDirectory(), + extension.getBackEdgeAnalysisCount(), + extension.isAnalyzeCycles(), + extension.isShowDetails(), + extension.isMinifyHtml(), + extension.isExcludeTests(), + extension.getTestSourceDirectory(), projectName, projectVersion, - baseDir, - RefactorFirstPlugin.relativizeToProject(baseDir, outputDir) - ); + projectDirectory, + outputDir.getAbsolutePath()); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginFunctionalTest.java b/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginFunctionalTest.java new file mode 100644 index 00000000..4f56f1bd --- /dev/null +++ b/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginFunctionalTest.java @@ -0,0 +1,355 @@ +package org.hjug.gradlereport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.eclipse.jgit.api.Git; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.hjug.refactorfirst.report.model.RefactorFirstReportDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Functional tests for the RefactorFirst Gradle plugin, using Gradle TestKit against a sample project. + * These tests pin the output-location contract and the JsonGenerator wiring. + */ +class RefactorFirstPluginFunctionalTest { + + @TempDir + File projectDir; + + @BeforeEach + void setUp() throws Exception { + newSampleProject(projectDir); + } + + /** T4: the full HTML report lands in build/reports/refactorfirst. */ + @Test + void htmlReportWritesToBuildReportsRefactorFirst() { + BuildResult result = runGradle(projectDir, "refactorFirstHtmlReport"); + assertNotNull(result.task(":refactorFirstHtmlReport")); + File report = new File(projectDir, "build/reports/refactorfirst/refactor-first-report.html"); + assertTrue(report.isFile(), "Expected HTML report at " + report); + assertTrue(report.length() > 0, "HTML report must not be empty"); + } + + /** T5: the simple HTML report lands in build/reports/refactorfirst. */ + @Test + void simpleHtmlReportWritesToBuildReportsRefactorFirst() { + BuildResult result = runGradle(projectDir, "refactorFirstSimpleHtmlReport"); + assertNotNull(result.task(":refactorFirstSimpleHtmlReport")); + File report = new File(projectDir, "build/reports/refactorfirst/refactor-first-report.html"); + assertTrue(report.isFile(), "Expected simple HTML report at " + report); + assertTrue(report.length() > 0, "Simple HTML report must not be empty"); + } + + /** T6: the CSV report lands in build/reports/refactorfirst with the contract file name. */ + @Test + void csvReportWritesToBuildReportsRefactorFirst() throws IOException { + BuildResult result = runGradle(projectDir, "refactorFirstCsvReport"); + assertNotNull(result.task(":refactorFirstCsvReport")); + File reportsDir = new File(projectDir, "build/reports/refactorfirst"); + assertTrue(reportsDir.isDirectory(), "Expected reports directory at " + reportsDir); + File[] csvFiles = reportsDir.listFiles((dir, name) -> name.matches("RefFirst_P.*_PV.*_PD.*\\.csv")); + assertNotNull(csvFiles); + assertTrue( + csvFiles.length == 1 && csvFiles[0].length() > 0, + "Expected exactly one non-empty RefFirst_P*_PV*_PD*.csv in " + reportsDir); + } + + /** + * T7: the JSON report runs through org.hjug.refactorfirst.report.JsonGenerator — + * it produces {@code .refactorfirst/refactor-first.json} plus the bundled viewer resources + * and never the legacy {@code refactor-first-data.json} of JsonReportExecutor. + */ + @Test + void jsonReportProducesJsonGeneratorOutput() throws IOException { + BuildResult result = runGradle(projectDir, "refactorFirstJsonReport"); + assertNotNull(result.task(":refactorFirstJsonReport")); + + File reportDir = new File(projectDir, ".refactorfirst"); + File json = new File(reportDir, "refactor-first.json"); + assertTrue(json.isFile(), "Expected JSON report at " + json); + assertTrue(json.length() > 0, "JSON report must not be empty"); + + // Viewer resources only copied by JsonGenerator + assertTrue(new File(reportDir, "index.html").isFile(), "Expected bundled viewer index.html"); + assertTrue( + new File(reportDir, "refactor-first-report.mustache").isFile(), + "Expected bundled refactor-first-report.mustache"); + + // Parses as the JsonGenerator DTO (not the legacy JsonReport DTO) + RefactorFirstReportDTO dto = new ObjectMapper().readValue(json, RefactorFirstReportDTO.class); + assertNotNull(dto.getProject(), "Expected RefactorFirstReportDTO with project metadata"); + + // refactor-first-data.json is only produced by the former JsonReportExecutor wiring + try (Stream paths = Files.walk(projectDir.toPath())) { + List legacy = paths.filter(p -> p.getFileName().toString().equals("refactor-first-data.json")) + .toList(); + assertTrue(legacy.isEmpty(), "refactor-first-data.json must not be created anywhere: " + legacy); + } + } + + /** T8: none of the legacy output locations are created by any task. */ + @Test + void noReportsAtLegacyLocations() throws IOException { + runGradle( + projectDir, + "refactorFirstHtmlReport", + "refactorFirstSimpleHtmlReport", + "refactorFirstCsvReport", + "refactorFirstJsonReport"); + + try (Stream paths = Files.walk(projectDir.toPath())) { + List legacy = paths.filter(p -> { + Path rel = projectDir.toPath().relativize(p); + String name = p.getFileName().toString(); + return rel.startsWith(Path.of("target", "site")) + || name.equals("refactor-first-data.json") + || (rel.getNameCount() == 1 && name.equals("reports")); + }) + .toList(); + assertTrue(legacy.isEmpty(), "No reports at legacy locations, found: " + legacy); + } + } + + /** T9: every task reports SUCCESS on repeated execution — never UP-TO-DATE or FROM-CACHE. */ + @Test + void tasksRunEveryTime() { + String[] tasks = { + "refactorFirstHtmlReport", + "refactorFirstSimpleHtmlReport", + "refactorFirstCsvReport", + "refactorFirstJsonReport" + }; + BuildResult first = runGradle(projectDir, tasks); + BuildResult second = runGradle(projectDir, tasks); + for (String taskName : tasks) { + assertEquals( + TaskOutcome.SUCCESS, + first.task(":" + taskName).getOutcome(), + "First run of " + taskName + " must succeed"); + assertEquals( + TaskOutcome.SUCCESS, + second.task(":" + taskName).getOutcome(), + "Second run of " + taskName + " must rerun, not be UP_TO_DATE/FROM_CACHE"); + } + } + + /** T10: repeated JSON runs regenerate output — guards against accidental caching. */ + @Test + void outputsChangeWhenSourceChanges() throws Exception { + runGradle(projectDir, "refactorFirstJsonReport"); + File json = new File(projectDir, ".refactorfirst/refactor-first.json"); + assertTrue(json.isFile(), "Expected JSON report after first run"); + String firstContent = Files.readString(json.toPath()); + + // Add a class so the class graph (and thus the JSON) provably changes + Path added = projectDir.toPath().resolve(Path.of("src", "main", "java", "com", "example", "Another.java")); + Files.writeString( + added, + """ + package com.example; + + public class Another { + public String echo(String value) { + return value; + } + } + """); + commitAll(projectDir, "Second commit"); + runGradle(projectDir, "refactorFirstJsonReport"); + String secondContent = Files.readString(json.toPath()); + + assertFalse(firstContent.equals(secondContent), "JSON report must be regenerated when sources change"); + } + + /** + * Version regression: apply() runs while the plugins {} block is being evaluated, before the build + * script sets the project version. The default version must therefore be read when the task is + * realized (after build-script evaluation), and the CSV file name must carry the real version. + */ + @Test + void csvReportFileNameUsesProjectVersionSetAfterPluginsBlock() { + try { + Files.writeString( + projectDir.toPath().resolve("build.gradle.kts"), + """ + plugins { id("org.hjug.refactorfirst") } + version = "1.2.3" + """); + commitAll(projectDir, "Set project version"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + runGradle(projectDir, "refactorFirstCsvReport"); + + File reportsDir = new File(projectDir, "build/reports/refactorfirst"); + assertTrue(reportsDir.isDirectory(), "Expected reports directory at " + reportsDir); + File[] csvFiles = reportsDir.listFiles((dir, name) -> name.matches("RefFirst_P.*_PV1\\.2\\.3_PD.*\\.csv")); + assertNotNull(csvFiles); + assertTrue( + csvFiles.length == 1 && csvFiles[0].length() > 0, + "Expected exactly one non-empty RefFirst_P*_PV1.2.3_PD*.csv in " + reportsDir); + } + + /** + * Multi-project build: the Git repository root is the root project, but the plugin is + * applied (and the task invoked) on a subproject beneath it. The CSV report must be + * written to the subproject's build directory even though projectDir != Git root. + */ + @Test + void csvReportWritesForSubprojectBeneathGitRoot() throws Exception { + Files.writeString( + projectDir.toPath().resolve("settings.gradle.kts"), + "rootProject.name = \"sample\"\ninclude(\"service\")\n"); + + Path serviceDir = projectDir.toPath().resolve("service"); + Files.createDirectories(serviceDir); + Files.writeString(serviceDir.resolve("build.gradle.kts"), "plugins { id(\"org.hjug.refactorfirst\") }\n"); + + Path source = serviceDir.resolve(Path.of("src", "main", "java", "com", "example", "Service.java")); + Files.createDirectories(source.getParent()); + Files.writeString( + source, + """ + package com.example; + + public class Service { + public String serve() { + return "served"; + } + } + """); + commitAll(projectDir, "Add service subproject"); + + BuildResult result = runGradle(projectDir, ":service:refactorFirstCsvReport"); + assertNotNull(result.task(":service:refactorFirstCsvReport")); + + File reportsDir = new File(serviceDir.toFile(), "build/reports/refactorfirst"); + assertTrue(reportsDir.isDirectory(), "Expected reports directory at " + reportsDir); + File[] csvFiles = reportsDir.listFiles((dir, name) -> name.matches("RefFirst_P.*_PV.*_PD.*\\.csv")); + assertNotNull(csvFiles); + assertTrue( + csvFiles.length == 1 && csvFiles[0].length() > 0, + "Expected exactly one non-empty RefFirst CSV in " + reportsDir); + } + + /** T12: extension overrides are honored by the HTML report task. */ + @Test + void extensionOverridesAreHonored() throws Exception { + Files.writeString( + projectDir.toPath().resolve("build.gradle.kts"), + """ + plugins { id("org.hjug.refactorfirst") } + refactorFirst { + outputDirectory = "build/rf" + projectName = "CustomName" + } + """); + commitAll(projectDir, "Configure extension"); + + runGradle(projectDir, "refactorFirstHtmlReport"); + + File report = new File(projectDir, "build/rf/refactor-first-report.html"); + assertTrue(report.isFile(), "Expected overridden output dir at " + report); + String html = Files.readString(report.toPath()); + assertTrue(html.contains("CustomName"), "Expected overridden project name in the report"); + + // The default location must not be used when overridden + assertFalse( + new File(projectDir, "build/reports/refactorfirst/refactor-first-report.html").exists(), + "Output must not land at the default location when outputDirectory is overridden"); + } + + /** + * Configuration-cache hygiene: the tasks must be configuration-cache compatible + * (no getProject() at execution time), so a stored entry can be reused with no problems. + */ + @Test + void configurationCacheRunHasNoProblems() { + BuildResult first = runGradleWithoutQuiet(projectDir, "refactorFirstJsonReport", "--configuration-cache"); + assertTrue( + first.getOutput().contains("Configuration cache entry stored"), + "Expected configuration cache entry to be stored:\n" + first.getOutput()); + BuildResult second = runGradleWithoutQuiet(projectDir, "refactorFirstJsonReport", "--configuration-cache"); + assertTrue( + second.getOutput().contains("Reusing configuration cache"), + "Expected configuration cache to be reused with no problems:\n" + second.getOutput()); + } + + /** Runs the given tasks against the sample project (quiet) and expects success. */ + static BuildResult runGradle(File projectDir, String... tasks) { + List arguments = new ArrayList<>(List.of(tasks)); + arguments.add("--quiet"); + return runGradleRaw(projectDir, arguments); + } + + /** Runs the given arguments against the sample project and expects success. */ + static BuildResult runGradleWithoutQuiet(File projectDir, String... args) { + return runGradleRaw(projectDir, List.of(args)); + } + + private static BuildResult runGradleRaw(File projectDir, List arguments) { + return GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(arguments) + .build(); + } + + static void commitAll(File dir, String message) throws Exception { + try (Git git = Git.open(dir)) { + git.add().addFilepattern(".").call(); + git.commit() + .setAuthor("RefactorFirst Test", "test@example.com") + .setMessage(message) + .call(); + } + } + + /** + * Creates a minimal Gradle project that applies the plugin and contains one Java class + * committed to a Git repository (the report layer early-returns without a .git dir). + */ + static void newSampleProject(File dir) throws Exception { + Files.writeString(dir.toPath().resolve("settings.gradle.kts"), "rootProject.name = \"sample\"\n"); + Files.writeString(dir.toPath().resolve("build.gradle.kts"), "plugins { id(\"org.hjug.refactorfirst\") }\n"); + + Path source = dir.toPath().resolve(Path.of("src", "main", "java", "com", "example", "Sample.java")); + Files.createDirectories(source.getParent()); + Files.writeString( + source, + """ + package com.example; + + public class Sample { + public String greet() { + return "hello"; + } + } + """); + + try (Git git = Git.init().setDirectory(dir).call()) { + git.add().addFilepattern(".").call(); + git.commit() + .setAuthor("RefactorFirst Test", "test@example.com") + .setMessage("Initial commit") + .call(); + } + } +} diff --git a/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginTest.java b/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginTest.java new file mode 100644 index 00000000..ac233128 --- /dev/null +++ b/refactor-first-gradle-plugin/src/test/java/org/hjug/gradlereport/RefactorFirstPluginTest.java @@ -0,0 +1,91 @@ +package org.hjug.gradlereport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.internal.TaskInternal; +import org.gradle.api.internal.TaskOutputsInternal; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RefactorFirstPlugin} task registration, using {@link ProjectBuilder}. + * These tests pin the plugin contract: plugin id, task names, and the always-run behavior. + */ +class RefactorFirstPluginTest { + + private static final String[] CONTRACT_TASK_NAMES = { + "refactorFirstHtmlReport", "refactorFirstSimpleHtmlReport", "refactorFirstCsvReport", "refactorFirstJsonReport" + }; + + private Project newProjectWithPlugin() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("org.hjug.refactorfirst"); + return project; + } + + /** T1: the plugin is applied by its contract id {@code org.hjug.refactorfirst}. */ + @Test + void appliesByContractId() { + Project project = newProjectWithPlugin(); + assertTrue(project.getPluginManager().hasPlugin("org.hjug.refactorfirst")); + } + + /** T2: all four contract task names are registered with group and description. */ + @Test + void registersAllFourTasksWithContractNames() { + Project project = newProjectWithPlugin(); + for (String taskName : CONTRACT_TASK_NAMES) { + Task task = project.getTasks().getByName(taskName); + assertNotNull(task, "Expected task " + taskName + " to be registered"); + assertEquals("RefactorFirst", task.getGroup(), "Unexpected group for task " + taskName); + assertTrue( + task.getDescription() != null && !task.getDescription().isBlank(), + "Expected a non-blank description for task " + taskName); + } + } + + /** T11: the extension mirrors the Maven plugin defaults. */ + @Test + void extensionDefaultsMatchContract() { + Project project = newProjectWithPlugin(); + RefactorFirstExtension extension = project.getExtensions().getByType(RefactorFirstExtension.class); + assertNotNull(extension, "Expected the refactorFirst extension to be created"); + assertFalse(extension.isShowDetails()); + assertEquals(50, extension.getBackEdgeAnalysisCount()); + assertTrue(extension.isAnalyzeCycles()); + assertFalse(extension.isMinifyHtml()); + assertTrue(extension.isExcludeTests()); + assertTrue(isBlank(extension.getTestSourceDirectory())); + assertTrue(isBlank(extension.getProjectName())); + assertTrue(isBlank(extension.getProjectVersion())); + assertTrue(extension.getOutputDirectory() == null, "No outputDirectory override by default"); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /** + * T3: tasks always run when invoked — never UP-TO-DATE, never FROM-CACHE. + * No declared inputs/outputs and an explicit never-up-to-date guard. + */ + @Test + void tasksAreNeverUpToDate() { + Project project = newProjectWithPlugin(); + for (String taskName : CONTRACT_TASK_NAMES) { + Task task = project.getTasks().getByName(taskName); + // Gradle exposes the composite up-to-date spec only via the internal API. + TaskOutputsInternal outputs = (TaskOutputsInternal) task.getOutputs(); + assertFalse( + outputs.getUpToDateSpec().isSatisfiedBy((TaskInternal) task), + "Task " + taskName + " must never be considered up-to-date"); + assertFalse( + task.getInputs().getHasInputs(), "Task " + taskName + " must not declare inputs (it always runs)"); + } + } +} diff --git a/refactor-first-maven-plugin/pom.xml b/refactor-first-maven-plugin/pom.xml index 52e2b64b..66999072 100644 --- a/refactor-first-maven-plugin/pom.xml +++ b/refactor-first-maven-plugin/pom.xml @@ -35,6 +35,7 @@ org.apache.maven maven-core + provided