diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1595db3714d41..a3620584bced5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,7 @@ jobs: with: distribution: temurin java-version: 17 + cache: maven - name: Setup Gradle uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3 diff --git a/.gitignore b/.gitignore index 073067bc6302f..c15d051ab0655 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ out .profile .rustup/ target +.flattened-pom.xml *.wasm /wasi-sdk* @@ -111,6 +112,8 @@ node_modules/ astro-docs/.netlify +coverage + # Angular Rspack Specific Options packages/angular-rspack/coverage packages/angular-rspack-compiler/coverage @@ -119,6 +122,7 @@ packages/angular-rspack-compiler/coverage packages/angular-rspack/README.md packages/angular-rspack-compiler/README.md packages/dotnet/README.md +packages/maven/README.md test-output test-results diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000000..ffcab66aa2834 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/CODEOWNERS b/CODEOWNERS index a2940d613bef7..20c0397d0ede3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -111,6 +111,10 @@ rust-toolchain.toml @nrwl/nx-native-reviewers /packages/gradle/** @FrozenPandaz @MaxKless @lourw /e2e/gradle/** @FrozenPandaz @MaxKless @lourw +# Maven +/packages/maven/** @FrozenPandaz @MaxKless @lourw +/e2e/maven/** @FrozenPandaz @MaxKless @lourw + # Nx-Plugin /packages/plugin/** @nrwl/nx-devkit-reviewers /e2e/plugin/** @nrwl/nx-devkit-reviewers diff --git a/astro-docs/project.json b/astro-docs/project.json index 5057b5975205d..322a7e8b89393 100644 --- a/astro-docs/project.json +++ b/astro-docs/project.json @@ -7,7 +7,7 @@ "continuous": true, "dependsOn": [ { - "projects": ["devkit", "create-nx-workspace", "dotnet"], + "projects": ["devkit", "create-nx-workspace", "dotnet", "maven"], "target": "build" } ], @@ -19,7 +19,7 @@ "build": { "dependsOn": [ { - "projects": ["devkit", "create-nx-workspace", "dotnet"], + "projects": ["devkit", "create-nx-workspace", "dotnet", "maven"], "target": "build" } ], diff --git a/astro-docs/sidebar.mts b/astro-docs/sidebar.mts index 855a5c7dc17c8..d1e7753d6f638 100644 --- a/astro-docs/sidebar.mts +++ b/astro-docs/sidebar.mts @@ -108,8 +108,22 @@ export const sidebar: StarlightUserConfig['sidebar'] = [ { label: 'Java', collapsed: true, - // when we have maven this will change to not have gradle as the top docs for Java - items: getPluginItems('gradle', 'java'), + items: [ + { + label: 'Introduction', + link: 'technologies/java/introduction', + }, + { + label: 'Gradle', + collapsed: true, + items: getPluginItems('gradle', 'java'), + }, + { + label: 'Maven', + collapsed: true, + items: getPluginItems('maven', 'java'), + }, + ], }, { label: '.NET', diff --git a/astro-docs/src/content/docs/technologies/java/gradle/index.mdoc b/astro-docs/src/content/docs/technologies/java/gradle/index.mdoc new file mode 100644 index 0000000000000..9a6e6f52c6fd1 --- /dev/null +++ b/astro-docs/src/content/docs/technologies/java/gradle/index.mdoc @@ -0,0 +1,9 @@ +--- +title: Gradle +sidebar: + hidden: true +description: Using Gradle with Nx +pagefind: false +--- + +{% index_page_cards path="technologies/java/gradle" /%} diff --git a/astro-docs/src/content/docs/technologies/java/gradle/introduction.mdoc b/astro-docs/src/content/docs/technologies/java/gradle/introduction.mdoc new file mode 100644 index 0000000000000..b2f118db454d3 --- /dev/null +++ b/astro-docs/src/content/docs/technologies/java/gradle/introduction.mdoc @@ -0,0 +1,301 @@ +--- +title: Overview of the Nx Plugin for Gradle +description: This plugin allows Gradle tasks to be run through Nx. +sidebar: + label: 'Introduction' +filter: 'type:References' +--- + +[Gradle](https://gradle.org/) is a fast, dependable, and adaptable open-source build automation tool with an elegant and extensible declarative build language. Gradle supports Android, Java, Kotlin Multiplatform, Groovy, Scala, Javascript, and C/C++. + +The Nx plugin for Gradle registers Gradle projects in your Nx workspace. It allows Gradle tasks to be run through Nx. Nx effortlessly makes your [CI faster](/docs/guides/nx-cloud/setup-ci). + +Nx adds the following features to your workspace: + +- [Cache task results](/docs/features/cache-task-results) +- [Distribute task execution](/docs/features/ci-features/distribute-task-execution) +- [Run only tasks affected by a PR](/docs/features/ci-features/affected) +- [Interactively explore your workspace](/docs/features/explore-graph) + +{% aside type="note" title="Java Compatibility" %} +This plugin requires Java 17 or newer. Using older Java versions is unsupported and may lead to issues. If you need support for an older version, please create an issue on [Github](https://github.com/nrwl/nx)! +{% /aside %} + +## Setup @nx/gradle + +### Install Nx + +You can install Nx globally. Depending on your package manager, use one of the following commands: + +{% tabs syncKey="package-manager" %} +{% tabitem label="npm" %} + +```shell {% frame="none" %} +npm add --global nx@latest +``` + +{% /tabitem %} +{% tabitem label="Homebrew (macOS, Linux)" %} + +```shell {% frame="none" %} +brew install nx +``` + +{% /tabitem %} +{% tabitem label="Chocolatey (Windows)" %} + +```shell {% frame="none" %} +choco install nx +``` + +{% /tabitem %} +{% tabitem label="apt (Ubuntu)" %} + +```shell {% frame="none" %} +sudo add-apt-repository ppa:nrwl/nx +sudo apt update +sudo apt install nx +``` + +{% /tabitem %} +{% /tabs %} + +### Add Nx to a Gradle Workspace + +In any Gradle workspace, run the following command to add Nx and the `@nx/gradle` plugin: + +```shell {% frame="none" %} +nx init +``` + +Then, you can run Gradle tasks using Nx. For example: + +```shell {% frame="none" %} +nx build +``` + +## How @nx/gradle Infers Tasks + +The `@nx/gradle` plugin relies on a companion Gradle plugin, `dev.nx.gradle.project-graph`, to analyze your Gradle build structure. When using `nx add`, the Gradle plugin is added as a dependency to the root Gradle build file. In most cases, the generator will add the task definition to trigger the plugin but if it's missing, add the following configuration to your Gradle configuration: + +{% tabs syncKey="java-lang" %} +{% tabitem label="build.gradle.kts" %} + +```kotlin +// build.gradle.kts +plugins { + id("dev.nx.gradle.project-graph") version("+") +} + +allprojects { + apply { + plugin("dev.nx.gradle.project-graph") + } +} +``` + +{% /tabitem %} +{% tabitem label="build.gradle" %} + +```groovy +// build.gradle +plugins { + id 'dev.nx.gradle.project-graph' version '+' +} + +allprojects { + apply plugin: 'dev.nx.gradle.project-graph' +} +``` + +{% /tabitem %} +{% /tabs %} + +The `dev.nx.gradle.project-graph` plugin introduces a task named `nxProjectGraph`. This task analyzes your Gradle projects and their tasks, outputting the structure as JSON. The `@nx/gradle` plugin then uses this JSON data to accurately build the Nx project graph. If Nx has any issue generate the project graph JSON, you can run the `nxProjectGraph` task manually: + +{% tabs syncKey="os" %} +{% tabitem label="Mac OS/Linux" %} + +```shell {% frame="none" %} +./gradlew nxProjectGraph +``` + +{% /tabitem %} +{% tabitem label="Windows" %} + +```shell {% frame="none" %} +.\gradlew.bat nxProjectGraph +``` + +{% /tabitem %} +{% /tabs %} + +## View Inferred Tasks + +To view inferred tasks for a project, open the [project details view](/docs/features/explore-graph#explore-projects-in-your-workspace) in Nx Console or run `nx show project my-project` in the command line. + +## Setting Up @nx/gradle in a Nx Workspace + +In any Nx workspace, you can install `@nx/gradle` by running the following command: + +```shell {% frame="none" %} +nx add @nx/gradle +``` + +## @nx/gradle Configuration + +The `@nx/gradle` is configured in the `plugins` array in `nx.json`. + +```json +// nx.json +{ + "plugins": [ + { + "plugin": "@nx/gradle", + "options": { + "testTargetName": "test", + "classesTargetName": "classes", + "buildTargetName": "build", + "ciTestTargetName": "test-ci", + "ciIntTestTargetName": "intTest-ci" + } + } + ] +} +``` + +Once a Gradle configuration file has been identified, the targets are created with the name you specify under `testTargetName`, `classesTargetName` or `buildTargetName` in the `nx.json` `plugins` array. The default names for the inferred targets are `test`, `classes` and `build`. + +### Test Distribution + +Nx provides powerful features for distributing tasks in CI, including test splitting (also known as atomization) and optimized build targets. For Gradle projects, this is facilitated by the `@nx/gradle` plugin, allowing you to run your tests and builds more efficiently in your Continuous Integration (CI) environment. + +#### How to Set Up Test Distribution (Atomizer) in CI + +To enable test distribution for your Gradle projects in CI, follow these steps: + +1. **Generate CI Workflow**: Run the `ci-workflow` generator to set up the necessary CI configurations. This generator creates a GitHub Actions workflow file that integrates with Nx's distributed task execution capabilities. + + ```shell {% frame="none" %} + nx g @nx/gradle:ci-workflow + ``` + + This command will generate a workflow file (e.g., `.github/workflows/ci.yml`) tailored for your Nx workspace with Gradle projects. + +2. **Configure `nx.json` for Atomizer**: Add or ensure the presence of `ciTestTargetName` or `ciIntTestTargetName` in the `@nx/gradle` plugin options within your `nx.json`. + + ```json {% meta="{7,8}" %} + // nx.json + { + "plugins": [ + { + "plugin": "@nx/gradle", + "options": { + "ciTestTargetName": "test-ci", + "ciIntTestTargetName": "intTest-ci" + } + } + ] + } + ``` + + Setting these options turns on the atomizer feature in CI. Nx will automatically split your testing tasks (unit and integration tests, respectively) by test class, allowing them to be run in a distributed fashion across your CI agents. + +3. **Update CI Workflow Command**: In your generated CI workflow file, modify the command used to run affected tasks. Instead of using a generic `build` target, leverage the `build-ci` target provided by the `@nx/gradle` plugin: + + ```shell {% frame="none" %} + # Before: + # ./nx affected --base=$NX_BASE --head=$NX_HEAD -t build + + # After: + ./nx affected --base=$NX_BASE --head=$NX_HEAD -t build-ci + ``` + + This ensures that your CI pipeline utilizes the optimized `build-ci` target, which is designed to integrate seamlessly with Nx's test distribution and caching mechanisms. + +#### The `ci-workflow` Generator + +The `@nx/gradle:ci-workflow` generator is a utility that automates the setup of a CI workflow for your Nx workspace containing Gradle projects. It creates a `.github/workflows` file (or equivalent for other CI providers) that includes steps for checking out code, setting up Java and Gradle, restoring caches, and running affected Nx tasks. Its primary purpose is to streamline the integration of Nx's CI features, such as distributed task execution and caching, into your existing CI pipeline. + +#### The `build-ci` Target + +The `@nx/gradle` plugin can create a `build-ci` target that is specifically designed for use in CI environments. This target allows for a more optimized and consistent build process by ensuring that the `check` task is rewired to its CI counterpart (`check-ci`), which also implies that test tasks (`test` and `intTest`) are rewired to their atomized `test-ci` and `intTest-ci` counterparts respectively. + +##### What is it? + +The `build-ci` target is a synthetic Nx target that acts as a placeholder for your Gradle `build` task in a CI context. Instead of directly running the `build` task, the `build-ci` target ensures that the `check` task (a dependency of `build`) first executes its CI-optimized version (`check-ci`), which in turn uses the split/atomized test tasks (`test-ci`, `intTest-ci`). This allows for distributed execution of tests and efficient caching in CI. + +##### How to Enable? + +To enable the `build-ci` target, you need to configure `ciTestTargetName` or `ciIntTestTargetName` in the `@nx/gradle` plugin options in your `nx.json`. + +For example: + +```json +// nx.json +{ + "plugins": [ + { + "plugin": "@nx/gradle", + "options": { + "ciTestTargetName": "test-ci", + "ciBuildTargetName": "build-ci" + } + } + ] +} +``` + +When `ciTestTargetName` (or `ciIntTestTargetName`) is set, the `build-ci` target is automatically created if the `build` task exists for a given Gradle project. + +##### Expected Behavior + +When you run `nx build-ci `, Nx will: + +1. Execute the `check-ci` task (if defined) instead of the standard `check` task. +2. The `check-ci` task will, in turn, trigger the atomized test tasks (`test-ci` and `intTest-ci`) if they are configured. +3. The `build-ci` target itself will use the `nx:noop` executor, meaning it doesn't execute a direct Gradle command, but rather relies on its dependencies (`check-ci`) to orchestrate the build process in a CI-friendly manner. +4. The `build-ci` target is cacheable. + +This setup ensures that your build process in CI leverages Nx's caching and distribution capabilities effectively. + +##### How to Turn it Off? + +To disable the `build-ci` target, simply remove the `ciBuildTargetName` option from the `@nx/gradle` plugin configuration in your `nx.json` file. If `ciTestTargetName` and `ciIntTestTargetName` are also removed, then the special CI targets for tests and check will also be turned off. + +### Continuous Tasks + +Gradle doesn't have a standard way to identify tasks which are [continuous](/docs/reference/project-configuration#continuous), like `bootRun` for serving a Spring Boot project. To ensure Nx handles these continuous tasks correctly, you can explicitly mark them as continuous. + +{% tabs %} +{% tabitem label="nx.json" %} + +In the `nx.json`, you can specify the target default configuration like so: + +```json {% meta="{5}" %} +// nx.json +{ + "targetDefaults": { + "someTask": { + "continuous": true + } + } +} +``` + +{% /tabitem %} +{% tabitem label="project.json" %} + +In a `project.json`, you can specify the target configuration like so: + +```json {% meta="{4}" %} +// project.json +{ + "someTask": { + "continuous": true + } +} +``` + +{% /tabitem %} +{% /tabs %} diff --git a/astro-docs/src/content/docs/technologies/java/introduction.mdoc b/astro-docs/src/content/docs/technologies/java/introduction.mdoc index b2f118db454d3..43e194f0fb3b3 100644 --- a/astro-docs/src/content/docs/technologies/java/introduction.mdoc +++ b/astro-docs/src/content/docs/technologies/java/introduction.mdoc @@ -1,301 +1,51 @@ --- -title: Overview of the Nx Plugin for Gradle -description: This plugin allows Gradle tasks to be run through Nx. +title: Java with Nx +description: Build scalable Java applications with Nx sidebar: label: 'Introduction' filter: 'type:References' --- -[Gradle](https://gradle.org/) is a fast, dependable, and adaptable open-source build automation tool with an elegant and extensible declarative build language. Gradle supports Android, Java, Kotlin Multiplatform, Groovy, Scala, Javascript, and C/C++. +Nx provides powerful tooling for Java projects, supporting both Gradle and Maven build systems. Whether you're working with Spring Boot, Micronaut, Quarkus, or any other Java framework, Nx helps you build faster and more efficiently. -The Nx plugin for Gradle registers Gradle projects in your Nx workspace. It allows Gradle tasks to be run through Nx. Nx effortlessly makes your [CI faster](/docs/guides/nx-cloud/setup-ci). +## Build System Support -Nx adds the following features to your workspace: +Nx offers dedicated plugins for the two most popular Java build tools: -- [Cache task results](/docs/features/cache-task-results) -- [Distribute task execution](/docs/features/ci-features/distribute-task-execution) -- [Run only tasks affected by a PR](/docs/features/ci-features/affected) -- [Interactively explore your workspace](/docs/features/explore-graph) +- **[@nx/gradle](/docs/technologies/java/gradle/introduction)** - For projects using Gradle +- **[@nx/maven](/docs/technologies/java/maven/introduction)** - For projects using Maven -{% aside type="note" title="Java Compatibility" %} -This plugin requires Java 17 or newer. Using older Java versions is unsupported and may lead to issues. If you need support for an older version, please create an issue on [Github](https://github.com/nrwl/nx)! -{% /aside %} - -## Setup @nx/gradle - -### Install Nx - -You can install Nx globally. Depending on your package manager, use one of the following commands: - -{% tabs syncKey="package-manager" %} -{% tabitem label="npm" %} +Both plugins provide the same core Nx features optimized for Java development. -```shell {% frame="none" %} -npm add --global nx@latest -``` +## What Nx Adds to Your Java Workspace -{% /tabitem %} -{% tabitem label="Homebrew (macOS, Linux)" %} +Nx enhances your Java development workflow with: -```shell {% frame="none" %} -brew install nx -``` +- **[Smart Caching](/docs/features/cache-task-results)** - Cache build and test results to avoid redundant work +- **[Distributed Task Execution](/docs/features/ci-features/distribute-task-execution)** - Run tasks in parallel across multiple machines in CI +- **[Affected Commands](/docs/features/ci-features/affected)** - Only build and test what changed +- **[Interactive Graph](/docs/features/explore-graph)** - Visualize your project dependencies +- **[CI Optimization](/docs/guides/nx-cloud/setup-ci)** - Make your CI pipeline dramatically faster -{% /tabitem %} -{% tabitem label="Chocolatey (Windows)" %} +## Getting Started -```shell {% frame="none" %} -choco install nx -``` +Choose your build tool to get started: -{% /tabitem %} -{% tabitem label="apt (Ubuntu)" %} +- [Get started with Gradle](/docs/technologies/java/gradle/introduction) +- [Get started with Maven](/docs/technologies/java/maven/introduction) -```shell {% frame="none" %} -sudo add-apt-repository ppa:nrwl/nx -sudo apt update -sudo apt install nx -``` +## Requirements -{% /tabitem %} -{% /tabs %} +{% aside type="note" title="Java Compatibility" %} +Both Nx plugins require Java 17 or newer. Using older Java versions is unsupported and may lead to issues. If you need support for an older version, please create an issue on [Github](https://github.com/nrwl/nx)! +{% /aside %} -### Add Nx to a Gradle Workspace +## Adding Nx to an Existing Java Project -In any Gradle workspace, run the following command to add Nx and the `@nx/gradle` plugin: +If you have an existing Gradle or Maven project, you can add Nx by running: ```shell {% frame="none" %} nx init ``` -Then, you can run Gradle tasks using Nx. For example: - -```shell {% frame="none" %} -nx build -``` - -## How @nx/gradle Infers Tasks - -The `@nx/gradle` plugin relies on a companion Gradle plugin, `dev.nx.gradle.project-graph`, to analyze your Gradle build structure. When using `nx add`, the Gradle plugin is added as a dependency to the root Gradle build file. In most cases, the generator will add the task definition to trigger the plugin but if it's missing, add the following configuration to your Gradle configuration: - -{% tabs syncKey="java-lang" %} -{% tabitem label="build.gradle.kts" %} - -```kotlin -// build.gradle.kts -plugins { - id("dev.nx.gradle.project-graph") version("+") -} - -allprojects { - apply { - plugin("dev.nx.gradle.project-graph") - } -} -``` - -{% /tabitem %} -{% tabitem label="build.gradle" %} - -```groovy -// build.gradle -plugins { - id 'dev.nx.gradle.project-graph' version '+' -} - -allprojects { - apply plugin: 'dev.nx.gradle.project-graph' -} -``` - -{% /tabitem %} -{% /tabs %} - -The `dev.nx.gradle.project-graph` plugin introduces a task named `nxProjectGraph`. This task analyzes your Gradle projects and their tasks, outputting the structure as JSON. The `@nx/gradle` plugin then uses this JSON data to accurately build the Nx project graph. If Nx has any issue generate the project graph JSON, you can run the `nxProjectGraph` task manually: - -{% tabs syncKey="os" %} -{% tabitem label="Mac OS/Linux" %} - -```shell {% frame="none" %} -./gradlew nxProjectGraph -``` - -{% /tabitem %} -{% tabitem label="Windows" %} - -```shell {% frame="none" %} -.\gradlew.bat nxProjectGraph -``` - -{% /tabitem %} -{% /tabs %} - -## View Inferred Tasks - -To view inferred tasks for a project, open the [project details view](/docs/features/explore-graph#explore-projects-in-your-workspace) in Nx Console or run `nx show project my-project` in the command line. - -## Setting Up @nx/gradle in a Nx Workspace - -In any Nx workspace, you can install `@nx/gradle` by running the following command: - -```shell {% frame="none" %} -nx add @nx/gradle -``` - -## @nx/gradle Configuration - -The `@nx/gradle` is configured in the `plugins` array in `nx.json`. - -```json -// nx.json -{ - "plugins": [ - { - "plugin": "@nx/gradle", - "options": { - "testTargetName": "test", - "classesTargetName": "classes", - "buildTargetName": "build", - "ciTestTargetName": "test-ci", - "ciIntTestTargetName": "intTest-ci" - } - } - ] -} -``` - -Once a Gradle configuration file has been identified, the targets are created with the name you specify under `testTargetName`, `classesTargetName` or `buildTargetName` in the `nx.json` `plugins` array. The default names for the inferred targets are `test`, `classes` and `build`. - -### Test Distribution - -Nx provides powerful features for distributing tasks in CI, including test splitting (also known as atomization) and optimized build targets. For Gradle projects, this is facilitated by the `@nx/gradle` plugin, allowing you to run your tests and builds more efficiently in your Continuous Integration (CI) environment. - -#### How to Set Up Test Distribution (Atomizer) in CI - -To enable test distribution for your Gradle projects in CI, follow these steps: - -1. **Generate CI Workflow**: Run the `ci-workflow` generator to set up the necessary CI configurations. This generator creates a GitHub Actions workflow file that integrates with Nx's distributed task execution capabilities. - - ```shell {% frame="none" %} - nx g @nx/gradle:ci-workflow - ``` - - This command will generate a workflow file (e.g., `.github/workflows/ci.yml`) tailored for your Nx workspace with Gradle projects. - -2. **Configure `nx.json` for Atomizer**: Add or ensure the presence of `ciTestTargetName` or `ciIntTestTargetName` in the `@nx/gradle` plugin options within your `nx.json`. - - ```json {% meta="{7,8}" %} - // nx.json - { - "plugins": [ - { - "plugin": "@nx/gradle", - "options": { - "ciTestTargetName": "test-ci", - "ciIntTestTargetName": "intTest-ci" - } - } - ] - } - ``` - - Setting these options turns on the atomizer feature in CI. Nx will automatically split your testing tasks (unit and integration tests, respectively) by test class, allowing them to be run in a distributed fashion across your CI agents. - -3. **Update CI Workflow Command**: In your generated CI workflow file, modify the command used to run affected tasks. Instead of using a generic `build` target, leverage the `build-ci` target provided by the `@nx/gradle` plugin: - - ```shell {% frame="none" %} - # Before: - # ./nx affected --base=$NX_BASE --head=$NX_HEAD -t build - - # After: - ./nx affected --base=$NX_BASE --head=$NX_HEAD -t build-ci - ``` - - This ensures that your CI pipeline utilizes the optimized `build-ci` target, which is designed to integrate seamlessly with Nx's test distribution and caching mechanisms. - -#### The `ci-workflow` Generator - -The `@nx/gradle:ci-workflow` generator is a utility that automates the setup of a CI workflow for your Nx workspace containing Gradle projects. It creates a `.github/workflows` file (or equivalent for other CI providers) that includes steps for checking out code, setting up Java and Gradle, restoring caches, and running affected Nx tasks. Its primary purpose is to streamline the integration of Nx's CI features, such as distributed task execution and caching, into your existing CI pipeline. - -#### The `build-ci` Target - -The `@nx/gradle` plugin can create a `build-ci` target that is specifically designed for use in CI environments. This target allows for a more optimized and consistent build process by ensuring that the `check` task is rewired to its CI counterpart (`check-ci`), which also implies that test tasks (`test` and `intTest`) are rewired to their atomized `test-ci` and `intTest-ci` counterparts respectively. - -##### What is it? - -The `build-ci` target is a synthetic Nx target that acts as a placeholder for your Gradle `build` task in a CI context. Instead of directly running the `build` task, the `build-ci` target ensures that the `check` task (a dependency of `build`) first executes its CI-optimized version (`check-ci`), which in turn uses the split/atomized test tasks (`test-ci`, `intTest-ci`). This allows for distributed execution of tests and efficient caching in CI. - -##### How to Enable? - -To enable the `build-ci` target, you need to configure `ciTestTargetName` or `ciIntTestTargetName` in the `@nx/gradle` plugin options in your `nx.json`. - -For example: - -```json -// nx.json -{ - "plugins": [ - { - "plugin": "@nx/gradle", - "options": { - "ciTestTargetName": "test-ci", - "ciBuildTargetName": "build-ci" - } - } - ] -} -``` - -When `ciTestTargetName` (or `ciIntTestTargetName`) is set, the `build-ci` target is automatically created if the `build` task exists for a given Gradle project. - -##### Expected Behavior - -When you run `nx build-ci `, Nx will: - -1. Execute the `check-ci` task (if defined) instead of the standard `check` task. -2. The `check-ci` task will, in turn, trigger the atomized test tasks (`test-ci` and `intTest-ci`) if they are configured. -3. The `build-ci` target itself will use the `nx:noop` executor, meaning it doesn't execute a direct Gradle command, but rather relies on its dependencies (`check-ci`) to orchestrate the build process in a CI-friendly manner. -4. The `build-ci` target is cacheable. - -This setup ensures that your build process in CI leverages Nx's caching and distribution capabilities effectively. - -##### How to Turn it Off? - -To disable the `build-ci` target, simply remove the `ciBuildTargetName` option from the `@nx/gradle` plugin configuration in your `nx.json` file. If `ciTestTargetName` and `ciIntTestTargetName` are also removed, then the special CI targets for tests and check will also be turned off. - -### Continuous Tasks - -Gradle doesn't have a standard way to identify tasks which are [continuous](/docs/reference/project-configuration#continuous), like `bootRun` for serving a Spring Boot project. To ensure Nx handles these continuous tasks correctly, you can explicitly mark them as continuous. - -{% tabs %} -{% tabitem label="nx.json" %} - -In the `nx.json`, you can specify the target default configuration like so: - -```json {% meta="{5}" %} -// nx.json -{ - "targetDefaults": { - "someTask": { - "continuous": true - } - } -} -``` - -{% /tabitem %} -{% tabitem label="project.json" %} - -In a `project.json`, you can specify the target configuration like so: - -```json {% meta="{4}" %} -// project.json -{ - "someTask": { - "continuous": true - } -} -``` - -{% /tabitem %} -{% /tabs %} +Nx will automatically detect your build tool and configure the appropriate plugin. diff --git a/astro-docs/src/content/docs/technologies/java/maven/index.mdoc b/astro-docs/src/content/docs/technologies/java/maven/index.mdoc new file mode 100644 index 0000000000000..5a030bdabcc94 --- /dev/null +++ b/astro-docs/src/content/docs/technologies/java/maven/index.mdoc @@ -0,0 +1,9 @@ +--- +title: Maven +sidebar: + hidden: true +description: Using Maven with Nx +pagefind: false +--- + +{% index_page_cards path="technologies/java/maven" /%} diff --git a/astro-docs/src/content/docs/technologies/java/maven/introduction.mdoc b/astro-docs/src/content/docs/technologies/java/maven/introduction.mdoc new file mode 100644 index 0000000000000..b5e54d1fa180e --- /dev/null +++ b/astro-docs/src/content/docs/technologies/java/maven/introduction.mdoc @@ -0,0 +1,121 @@ +--- +title: Overview of the Nx Plugin for Maven +description: This plugin allows Maven tasks to be run through Nx. +sidebar: + label: 'Introduction' +filter: 'type:References' +--- + +{% aside type="caution" title="Experimental Plugin" %} +The `@nx/maven` plugin is currently experimental. Features and APIs may change. +{% /aside %} + +[Apache Maven](https://maven.apache.org/) is a build tool for Java projects. Using a project object model (POM), Maven manages a project's compilation, testing, and documentation. + +The Nx plugin for Maven registers Maven projects in your Nx workspace. It allows Maven tasks to be run through Nx. Nx effortlessly makes your [CI faster](/docs/guides/nx-cloud/setup-ci). + +Nx adds the following features to your workspace: + +- [Cache task results](/docs/features/cache-task-results) +- [Distribute task execution](/docs/features/ci-features/distribute-task-execution) +- [Run only tasks affected by a PR](/docs/features/ci-features/affected) +- [Interactively explore your workspace](/docs/features/explore-graph) + +{% aside type="note" title="Java and Maven Compatibility" %} +This plugin requires: + +- **Java 17 or newer** - Using older Java versions is unsupported and may lead to issues. +- **Maven 3.6.0 or newer** - Older Maven versions are not supported. + +If you need support for an older version, please create an issue on [Github](https://github.com/nrwl/nx)! +{% /aside %} + +## Setup @nx/maven + +### Install Nx + +You can install Nx globally. Depending on your package manager, use one of the following commands: + +{% tabs syncKey="package-manager" %} +{% tabitem label="npm" %} + +```shell {% frame="none" %} +npm add --global nx@latest +``` + +{% /tabitem %} +{% tabitem label="Homebrew (macOS, Linux)" %} + +```shell {% frame="none" %} +brew install nx +``` + +{% /tabitem %} +{% tabitem label="Chocolatey (Windows)" %} + +```shell {% frame="none" %} +choco install nx +``` + +{% /tabitem %} +{% tabitem label="apt (Ubuntu)" %} + +```shell {% frame="none" %} +sudo add-apt-repository ppa:nrwl/nx +sudo apt update +sudo apt install nx +``` + +{% /tabitem %} +{% /tabs %} + +### Add Nx to a Maven Workspace + +In any Maven workspace, run the following command to add Nx and the `@nx/maven` plugin: + +```shell {% frame="none" %} +nx init +``` + +Then, you can run Maven tasks using Nx. For example: + +```shell {% frame="none" %} +nx build +``` + +## How @nx/maven Infers Tasks + +The `@nx/maven` plugin automatically detects Maven projects in your workspace by scanning for `pom.xml` files. It analyzes your Maven build structure to create Nx targets for common Maven lifecycle phases and plugin goals. + +## View Inferred Tasks + +To view inferred tasks for a project, open the [project details view](/docs/features/explore-graph#explore-projects-in-your-workspace) in Nx Console or run `nx show project my-project` in the command line. + +## Setting Up @nx/maven in a Nx Workspace + +In any Nx workspace, you can install `@nx/maven` by running the following command: + +```shell {% frame="none" %} +nx add @nx/maven +``` + +## @nx/maven Configuration + +The `@nx/maven` is configured in the `plugins` array in `nx.json`. + +```json +// nx.json +{ + "plugins": [ + { + "plugin": "@nx/maven", + "options": { + "testTargetName": "test", + "buildTargetName": "build" + } + } + ] +} +``` + +Once a Maven configuration file has been identified, the targets are created with the name you specify under `testTargetName` or `buildTargetName` in the `nx.json` `plugins` array. The default names for the inferred targets are `test` and `build`. diff --git a/astro-docs/src/plugins/utils/plugin-mappings.ts b/astro-docs/src/plugins/utils/plugin-mappings.ts index 2c4345609fa9f..115446bfa3d96 100644 --- a/astro-docs/src/plugins/utils/plugin-mappings.ts +++ b/astro-docs/src/plugins/utils/plugin-mappings.ts @@ -364,11 +364,6 @@ export function pluginSpecialCasePluginRemapping(pluginName: string) { // we call the js plugin `typescript` in the URLs technologies case 'js': return 'typescript'; - // we make the default java pages be the gradle impl atm. - // this will probs change with maven - case 'gradle': - case 'java': - return 'java'; default: return pluginName; } diff --git a/astro-docs/src/plugins/utils/plugin-schema-parser.ts b/astro-docs/src/plugins/utils/plugin-schema-parser.ts index 4917948e74252..3dc2e7e28e043 100644 --- a/astro-docs/src/plugins/utils/plugin-schema-parser.ts +++ b/astro-docs/src/plugins/utils/plugin-schema-parser.ts @@ -66,10 +66,8 @@ export function parseGenerators( const schemaPath = join(pluginPath, config.schema); - if (existsSync(schemaPath)) { - const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); - generators.set(name, { config, schema, schemaPath }); - } + const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); + generators.set(name, { config, schema, schemaPath }); } return generators; diff --git a/e2e/maven/jest.config.ts b/e2e/maven/jest.config.ts new file mode 100644 index 0000000000000..e4755e3f023e0 --- /dev/null +++ b/e2e/maven/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'e2e-maven', + preset: '../jest.preset.e2e.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + globalSetup: '../utils/global-setup.ts', + globalTeardown: '../utils/global-teardown.ts', + testTimeout: 500000, + maxWorkers: 1, + coverageDirectory: '../../coverage/e2e/maven', +}; diff --git a/e2e/maven/package.json b/e2e/maven/package.json new file mode 100644 index 0000000000000..19d2c7071bebe --- /dev/null +++ b/e2e/maven/package.json @@ -0,0 +1,9 @@ +{ + "name": "e2e-maven", + "version": "0.0.1", + "private": true, + "dependencies": { + "@nx/e2e-utils": "workspace:*", + "extract-zip": "^2.0.1" + } +} diff --git a/e2e/maven/project.json b/e2e/maven/project.json new file mode 100644 index 0000000000000..6383e748d7b97 --- /dev/null +++ b/e2e/maven/project.json @@ -0,0 +1,9 @@ +{ + "name": "e2e-maven", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/maven", + "projectType": "application", + "implicitDependencies": ["maven", "nx-maven-plugin"], + "// targets": "to see all targets run: nx show project e2e-maven --web", + "targets": {} +} diff --git a/e2e/maven/src/maven-simple.test.ts b/e2e/maven/src/maven-simple.test.ts new file mode 100644 index 0000000000000..9834af2c9af87 --- /dev/null +++ b/e2e/maven/src/maven-simple.test.ts @@ -0,0 +1,31 @@ +import { + cleanupProject, + createEmptyProjectDirectory, + openInEditor, + runCLI, + uniq, +} from '@nx/e2e-utils'; + +import { createMavenProject } from './utils/create-maven-project'; + +describe('Maven (Simple)', () => { + let mavenProjectName = uniq('my-maven-project'); + + beforeAll(async () => { + createEmptyProjectDirectory(mavenProjectName); + await createMavenProject(mavenProjectName); + openInEditor(); + runCLI('init --no-interactive'); + runCLI(`add @nx/maven`); + }); + + afterAll(() => cleanupProject()); + + it('should initialize @nx/maven', () => { + const projects = runCLI(`show projects`); + expect(projects).toContain('app'); + expect(projects).toContain('lib'); + expect(projects).toContain('utils'); + expect(projects).toContain(mavenProjectName); + }); +}); diff --git a/e2e/maven/src/maven.test.ts b/e2e/maven/src/maven.test.ts new file mode 100644 index 0000000000000..a8196ac94b572 --- /dev/null +++ b/e2e/maven/src/maven.test.ts @@ -0,0 +1,85 @@ +import { + checkFilesExist, + cleanupProject, + newProject, + runCLI, + uniq, + readJson, +} from '@nx/e2e-utils'; + +import { createMavenProject } from './utils/create-maven-project'; + +describe('Maven', () => { + let mavenProjectName = uniq('my-maven-project'); + + beforeAll(async () => { + newProject({ + preset: 'apps', + packages: ['@nx/maven'], + }); + await createMavenProject(mavenProjectName); + runCLI(`add @nx/maven`); + }); + + afterAll(() => cleanupProject()); + + it('should detect Maven projects', () => { + console.log(readJson('nx.json')); + const projects = runCLI(`show projects`); + expect(projects).toContain('app'); + expect(projects).toContain('lib'); + expect(projects).toContain('utils'); + expect(projects).toContain(mavenProjectName); + }); + + it('should have proper Maven targets', () => { + const output = runCLI('show project app --json=false'); + + // Check that the project name appears + expect(output).toContain('Name: com.example:app'); + + // Check that install target appears + expect(output).toContain('- install:'); + + // Check that install-ci target appears + expect(output).toContain('- install-ci:'); + }); + + it('should build Maven project with dependencies', () => { + // Build app which depends on lib, which depends on utils + let buildOutput = runCLI('run app:install', { verbose: true }); + + // Should build dependencies first + expect(buildOutput).toContain('BUILD SUCCESS'); + + checkFilesExist( + `app/target/app-1.0.0-SNAPSHOT.jar`, + `lib/target/lib-1.0.0-SNAPSHOT.jar`, + `utils/target/utils-1.0.0-SNAPSHOT.jar` + ); + }); + + it('should run tests for Maven project', () => { + const testOutput = runCLI('run utils:test', { verbose: true }); + expect(testOutput).toContain('BUILD SUCCESS'); + }); + + it('should handle Maven project with complex dependencies', () => { + // Verify that app's dependencies are tracked correctly + runCLI('graph --file=graph.json'); + const graph = readJson('graph.json'); + + // Check that dependencies exist in the graph + const appDeps = graph.graph.dependencies['com.example:app']; + expect(appDeps).toContainEqual({ + source: 'com.example:app', + target: 'com.example:lib', + type: 'static', + }); + expect(appDeps).toContainEqual({ + source: 'com.example:app', + target: `com.example:${mavenProjectName}`, + type: 'static', + }); + }); +}); diff --git a/e2e/maven/src/utils/create-maven-project.ts b/e2e/maven/src/utils/create-maven-project.ts new file mode 100644 index 0000000000000..8de0a3ba34798 --- /dev/null +++ b/e2e/maven/src/utils/create-maven-project.ts @@ -0,0 +1,263 @@ +import { + e2eConsoleLogger, + tmpProjPath, + readFile, + updateFile, +} from '@nx/e2e-utils'; +import { execSync } from 'child_process'; +import { writeFileSync } from 'fs-extra'; +import { join } from 'path'; +import { + readFileSync, + writeFileSync as fsWriteFileSync, + unlinkSync, + chmodSync, +} from 'fs'; +import * as extract from 'extract-zip'; + +async function downloadFile( + url: string, + params: Record, + outputPath: string +): Promise { + e2eConsoleLogger(`Downloading from ${url} to ${outputPath}...`); + e2eConsoleLogger(`Parameters: ${JSON.stringify(params, null, 2)}`); + + const formData = new URLSearchParams(params); + + e2eConsoleLogger(`Making POST request...`); + const response = await fetch(url, { + method: 'POST', + body: formData, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + e2eConsoleLogger( + `Response status: ${response.status} ${response.statusText}` + ); + + if (!response.ok) { + const errorText = await response.text(); + e2eConsoleLogger(`Error response body: ${errorText}`); + throw new Error( + `HTTP error! status: ${response.status}, body: ${errorText}` + ); + } + + e2eConsoleLogger(`Reading response as array buffer...`); + const buffer = await response.arrayBuffer(); + e2eConsoleLogger(`Buffer size: ${buffer.byteLength} bytes`); + + e2eConsoleLogger(`Writing to file: ${outputPath}`); + fsWriteFileSync(outputPath, Buffer.from(buffer)); + e2eConsoleLogger(`File written successfully`); +} + +export async function createMavenProject( + projectName: string, + cwd: string = tmpProjPath(), + addProjectJsonNamePrefix: string = '' +) { + e2eConsoleLogger(`Using java version: ${execSync('java -version 2>&1')}`); + + e2eConsoleLogger( + 'Creating multi-module Maven project using Spring Initializr...' + ); + + // Create parent POM (Spring Initializr doesn't support packaging=pom) + const parentPomPath = join(cwd, 'pom.xml'); + const parentPom = ` + + 4.0.0 + + com.example + ${projectName} + 1.0.0-SNAPSHOT + pom + + ${projectName} + + + app + lib + utils + + + + 17 + 17 + UTF-8 + 3.4.0 + + + + + + org.springframework.boot + spring-boot-dependencies + \${spring-boot.version} + pom + import + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M9 + + + org.springframework.boot + spring-boot-maven-plugin + \${spring-boot.version} + + + + +`; + + writeFileSync(parentPomPath, parentPom); + + // Create modules with dependencies: app -> lib -> utils + await createModule( + cwd, + 'app', + projectName, + ['lib'], + addProjectJsonNamePrefix + ); + await createModule( + cwd, + 'lib', + projectName, + ['utils'], + addProjectJsonNamePrefix + ); + await createModule(cwd, 'utils', projectName, [], addProjectJsonNamePrefix); + + updateFile('mvnw', readFile('app/mvnw')); + updateFile('mvnw.cmd', readFile('app/mvnw.cmd')); + updateFile( + '.mvn/wrapper/maven-wrapper.properties', + readFile('app/.mvn/wrapper/maven-wrapper.properties') + ); + + chmodSync(join(cwd, 'mvnw'), 0o755); + chmodSync(join(cwd, 'mvnw.cmd'), 0o755); + + e2eConsoleLogger('Created multi-module Maven project with Spring Boot'); +} + +async function createModule( + basePath: string, + moduleName: string, + parentArtifactId: string, + dependencies: string[], + addProjectJsonNamePrefix: string = '' +) { + const modulePath = join(basePath, moduleName); + + e2eConsoleLogger(`Creating ${moduleName} module...`); + + // Generate Spring Boot module + const zipPath = join(basePath, `${moduleName}.zip`); + await downloadFile( + 'https://start.spring.io/starter.zip', + { + type: 'maven-project', + language: 'java', + bootVersion: '3.4.0', + baseDir: moduleName, + groupId: 'com.example', + artifactId: moduleName, + name: capitalize(moduleName), + packageName: `com.example.${moduleName}`, + javaVersion: '17', + dependencies: 'web', + }, + zipPath + ); + + // Unzip the module using Node.js (cross-platform) + e2eConsoleLogger(`Unzipping ${moduleName}.zip to ${basePath}...`); + + await extract(zipPath, { dir: basePath }); + + // Delete zip file + unlinkSync(zipPath); + + e2eConsoleLogger(`Extracted and cleaned up ${moduleName}.zip`); + + // Modify the module's POM to link to parent and add dependencies + const pomPath = join(modulePath, 'pom.xml'); + let pomContent = readFileSync(pomPath, 'utf-8'); + + // Replace Spring Boot starter parent with our custom parent + pomContent = pomContent.replace( + /[\s\S]*?<\/parent>/, + ` + com.example + ${parentArtifactId} + 1.0.0-SNAPSHOT + ` + ); + + // Remove the project's standalone groupId and version (after , before ) + // Match from after to before to avoid touching parent's groupId + pomContent = pomContent.replace( + /(<\/parent>[\s\S]*?)com\.example<\/groupId>\s*/, + '$1' + ); + pomContent = pomContent.replace( + /(<\/parent>[\s\S]*?)[^<]*<\/version>\s*(?=)/, + '$1' + ); + + // Add inter-module dependencies if any + if (dependencies.length > 0) { + const depsXml = dependencies + .map( + (dep) => ` + com.example + ${dep} + 1.0.0-SNAPSHOT + ` + ) + .join('\n'); + + pomContent = pomContent.replace( + //, + `\n${depsXml}` + ); + } + + writeFileSync(pomPath, pomContent); + + // Add project.json if needed + if (addProjectJsonNamePrefix) { + writeFileSync( + join(modulePath, 'project.json'), + `{"name": "${addProjectJsonNamePrefix}${moduleName}"}` + ); + } + + e2eConsoleLogger(`Created ${moduleName} module`); +} + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/e2e/maven/tsconfig.json b/e2e/maven/tsconfig.json new file mode 100644 index 0000000000000..c0122ffe25c36 --- /dev/null +++ b/e2e/maven/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../utils" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/e2e/maven/tsconfig.spec.json b/e2e/maven/tsconfig.spec.json new file mode 100644 index 0000000000000..9b2a121d114b6 --- /dev/null +++ b/e2e/maven/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/e2e/utils/create-project-utils.ts b/e2e/utils/create-project-utils.ts index 5e2d6eac75d98..823fffc96d21d 100644 --- a/e2e/utils/create-project-utils.ts +++ b/e2e/utils/create-project-utils.ts @@ -48,6 +48,7 @@ const nxPackages = [ `@nx/jest`, `@nx/js`, `@nx/eslint`, + '@nx/maven', `@nx/nest`, `@nx/next`, `@nx/node`, @@ -71,6 +72,15 @@ const nxPackages = [ type NxPackage = (typeof nxPackages)[number]; +export function openInEditor(projectDirectory: string = tmpProjPath()) { + if (process.env.NX_E2E_EDITOR) { + const editor = process.env.NX_E2E_EDITOR; + execSync(`${editor} ${projectDirectory}`, { + stdio: 'inherit', + }); + } +} + /** * Sets up a new project in the temporary project path * for the currently selected CLI. @@ -207,12 +217,7 @@ ${ ); } - if (process.env.NX_E2E_EDITOR) { - const editor = process.env.NX_E2E_EDITOR; - execSync(`${editor} ${projectDirectory}`, { - stdio: 'inherit', - }); - } + openInEditor(projectDirectory); return projScope; } catch (e) { logError(`Failed to set up project for e2e tests.`, e.message); @@ -708,6 +713,11 @@ export function createNonNxProjectDirectory( ); } +export function createEmptyProjectDirectory(name = uniq('proj')) { + projName = name; + ensureDirSync(tmpProjPath()); +} + export function uniq(prefix: string): string { // We need to ensure that the length of the random section of the name is of consistent length to avoid flakiness in tests const randomSevenDigitNumber = Math.floor(Math.random() * 10000000) diff --git a/mvnw b/mvnw new file mode 100755 index 0000000000000..bd8896bf2217b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000000000..5761d948924c1 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/nx-parent.iml b/nx-parent.iml new file mode 100644 index 0000000000000..29993e805f30e --- /dev/null +++ b/nx-parent.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/nx.json b/nx.json index 0c6ca02ce7bf2..bfd68988f1be7 100644 --- a/nx.json +++ b/nx.json @@ -140,7 +140,8 @@ "inputs": ["e2eInputs", "^production"], "dependsOn": [ "@nx/nx-source:populate-local-registry-storage", - "@nx/nx-source:local-registry" + "@nx/nx-source:local-registry", + "nx-maven-plugin:install" ] }, "e2e-ci": { @@ -161,7 +162,8 @@ "inputs": ["e2eInputs", "^production"], "dependsOn": [ "@nx/nx-source:populate-local-registry-storage", - "@nx/nx-source:local-registry" + "@nx/nx-source:local-registry", + "nx-maven-plugin:install" ], "options": { "args": ["--forceExit"] @@ -171,7 +173,8 @@ "inputs": ["e2eInputs", "^production"], "dependsOn": [ "@nx/nx-source:populate-local-registry-storage", - "@nx/nx-source:local-registry" + "@nx/nx-source:local-registry", + "nx-maven-plugin:install" ] }, "e2e-base": { diff --git a/packages/gradle/README.md b/packages/gradle/README.md index 8ae7a5f863e87..dfcbc528dd690 100644 --- a/packages/gradle/README.md +++ b/packages/gradle/README.md @@ -9,12 +9,12 @@
-> Note: this plugin is currently experimental. +> Note: The `@nx/gradle` plugin is currently experimental. Features and APIs may change. # Nx: Smart Repos · Fast Builds Get to green PRs in half the time. Nx optimizes your builds, scales your CI, and fixes failed PRs. Built for developers and AI agents. -This package is a [Gradle plugin for Nx](https://nx.dev/gradle/overview). +This package is a [Gradle plugin for Nx](https://nx.dev/technologies/java/gradle/introduction). {{content}} diff --git a/packages/maven/.eslintrc.json b/packages/maven/.eslintrc.json new file mode 100644 index 0000000000000..9566070ceab7d --- /dev/null +++ b/packages/maven/.eslintrc.json @@ -0,0 +1,42 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "node_modules"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredDependencies": ["nx"] + } + ] + } + }, + { + "files": [ + "./package.json", + "./generators.json", + "./executors.json", + "./migrations.json" + ], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + } + ] +} diff --git a/packages/maven/README.md__tpl__ b/packages/maven/README.md__tpl__ new file mode 100644 index 0000000000000..76b2e86cfeac4 --- /dev/null +++ b/packages/maven/README.md__tpl__ @@ -0,0 +1,20 @@ +

+ + + Nx - Smart Repos · Fast Builds + +

+ +{{links}} + +
+ +> Note: The `@nx/maven` plugin is currently experimental. Features and APIs may change. + +# Nx: Smart Repos · Fast Builds + +Get to green PRs in half the time. Nx optimizes your builds, scales your CI, and fixes failed PRs. Built for developers and AI agents. + +This package is a [Maven plugin for Nx](https://nx.dev/technologies/java/maven/introduction). + +{{content}} diff --git a/packages/maven/executors.json b/packages/maven/executors.json new file mode 100644 index 0000000000000..2855b15d9b935 --- /dev/null +++ b/packages/maven/executors.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../node_modules/nx/schemas/executors-schema.json", + "executors": {} +} diff --git a/packages/maven/generators.json b/packages/maven/generators.json new file mode 100644 index 0000000000000..1851ca10c3f37 --- /dev/null +++ b/packages/maven/generators.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../node_modules/nx/schemas/generators-schema.json", + "generators": { + "init": { + "implementation": "./dist/generators/init/generator", + "schema": "./dist/generators/init/schema.json", + "description": "Initialize Maven support in an Nx workspace" + } + } +} diff --git a/packages/maven/jest.config.ts b/packages/maven/jest.config.ts new file mode 100644 index 0000000000000..81f74a4dd5289 --- /dev/null +++ b/packages/maven/jest.config.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +export default { + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html', 'json'], + globals: {}, + displayName: 'maven', + preset: '../../jest.preset.js', +}; diff --git a/packages/maven/maven-plugin/pom.xml b/packages/maven/maven-plugin/pom.xml new file mode 100644 index 0000000000000..e26227cef4995 --- /dev/null +++ b/packages/maven/maven-plugin/pom.xml @@ -0,0 +1,310 @@ + + + 4.0.0 + + + dev.nx + nx-parent + 0.0.6-SNAPSHOT + ../../../pom.xml + + + dev.nx.maven + nx-maven-plugin + ${project.parent.version} + maven-plugin + + Nx Maven Plugin + Maven plugin for Nx integration and project analysis + https://github.com/nrwl/nx + + + + MIT License + https://github.com/nrwl/nx/blob/master/LICENSE + repo + + + + + + Nx Team + hello@nrwl.io + Nx + https://nx.dev + + + + + scm:git:git://github.com/nrwl/nx.git + scm:git:ssh://github.com:nrwl/nx.git + https://github.com/nrwl/nx + + + + + false + false + false + + + + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.tools.version} + provided + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + + + + + org.apache.maven + maven-model + ${maven.version} + provided + + + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + org.jetbrains.kotlin + kotlin-test + ${kotlin.version} + test + + + + + org.apache.maven.plugin-testing + maven-plugin-testing-harness + 4.0.0-beta-4 + test + + + + + de.skuzzle.test + snapshot-tests-junit5 + 1.11.0 + test + + + + + de.skuzzle.test + snapshot-tests-json + 1.11.0 + test + + + + + junit + junit + 4.13.2 + test + + + + + org.junit.jupiter + junit-jupiter + ${junit5.version} + test + + + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + org.eclipse.jgit + org.eclipse.jgit + 6.8.0.202311291450-r + + + + + + org.codehaus.plexus + plexus-utils + 3.5.1 + + + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + org.apache.maven.plugins + maven-plugin-plugin + + nx + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit4 + 3.2.5 + + + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + true + ${project.build.directory} + + + + src/main/kotlin + src/test/kotlin + + + + + release + + + + org.apache.maven.plugins + maven-source-plugin + + + + org.jetbrains.dokka + dokka-maven-plugin + ${dokka.version} + + + attach-javadocs + package + + javadocJar + + + + + + + org.jetbrains.dokka + kotlin-as-java-plugin + ${dokka.version} + + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + org.sonatype.central + central-publishing-maven-plugin + + + + + + diff --git a/packages/maven/maven-plugin/project.json b/packages/maven/maven-plugin/project.json new file mode 100644 index 0000000000000..a0620adab85e6 --- /dev/null +++ b/packages/maven/maven-plugin/project.json @@ -0,0 +1,14 @@ +{ + "name": "nx-maven-plugin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/maven/maven-plugin", + "projectType": "library", + "targets": { + "install": { + "command": "./mvnw install" + }, + "_install": { + "command": "node scripts/build-maven-analyzer.js" + } + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt new file mode 100644 index 0000000000000..9da34caee5ad0 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt @@ -0,0 +1,86 @@ +package dev.nx.maven + +import org.eclipse.jgit.ignore.FastIgnoreRule +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Determines if files match gitignore patterns + * Provides heuristic for parameter classification: ignored files are likely outputs, tracked files are likely inputs + */ +class GitIgnoreClassifier( + private val workspaceRoot: File +) { + private val log = LoggerFactory.getLogger(GitIgnoreClassifier::class.java) + + private val ignoreRules: MutableList = mutableListOf() + + init { + loadIgnoreRules() + } + + private fun loadIgnoreRules() { + + try { + val gitIgnoreFile = File(workspaceRoot, ".gitignore") + if (gitIgnoreFile.exists()) { + gitIgnoreFile.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { + try { + val rule = FastIgnoreRule(trimmed) + ignoreRules.add(rule) + log.debug("Loaded gitignore rule: $trimmed") + } catch (e: Exception) { + log.debug("Failed to parse gitignore rule '$trimmed': ${e.message}") + } + } + } + log.debug("Loaded ${ignoreRules.size} gitignore rules") + } else { + log.debug("No .gitignore file found at: ${gitIgnoreFile.path}") + } + } catch (e: Exception) { + log.debug("Error loading gitignore rules: ${e.message}") + } + } + + /** + * Determines if a file path should be ignored according to gitignore rules + * Works for both existing and non-existent paths by using pattern matching + */ + fun isIgnored(path: File): Boolean { + if (ignoreRules.isEmpty()) { + return false + } + + val relativePath = try { + path.relativeTo(workspaceRoot).path + } catch (e: IllegalArgumentException) { + // Path is outside workspace + return false + } + + return try { + // Check path against all ignore rules + var isIgnored = false + + for (rule in ignoreRules) { + val isDirectory = path.isDirectory || relativePath.endsWith("/") + if (rule.isMatch(relativePath, isDirectory)) { + // FastIgnoreRule.getResult() returns true if should be ignored + isIgnored = rule.result + log.debug("Path '$relativePath' matched rule, ignored: $isIgnored") + } + } + + log.debug("Path '$relativePath' final ignored status: $isIgnored") + isIgnored + + } catch (e: Exception) { + log.debug("Error checking ignore status for path '$path': ${e.message}") + false + } + } + +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzer.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzer.kt new file mode 100644 index 0000000000000..6e4f7ec326b60 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzer.kt @@ -0,0 +1,127 @@ +package dev.nx.maven + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import dev.nx.maven.targets.NxTargetFactory +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Analyzer for a single Maven project structure to generate JSON for Nx integration + * This is a simplified, per-project analyzer that doesn't require cross-project coordination + */ +class NxProjectAnalyzer( + private val project: MavenProject, + private val workspaceRoot: File, + private val nxTargetFactory: NxTargetFactory, + private val mavenCommand: String +) { + private val objectMapper = ObjectMapper() + private val log: Logger = LoggerFactory.getLogger(NxProjectAnalyzer::class.java) + + /** + * Analyzes the project and returns Nx project config + */ + fun analyze(): ProjectAnalysis { + val startTime = System.currentTimeMillis() + log.info("Starting analysis for project: ${project.artifactId}") + + // Calculate relative path from workspace root + // Canonicalize both paths to resolve symlinks (e.g., /tmp -> /private/tmp on macOS) + val pathCalculationStart = System.currentTimeMillis() + val canonicalWorkspaceRoot = workspaceRoot.canonicalFile + val canonicalBasedir = project.basedir.canonicalFile + val root = canonicalBasedir.relativeTo(canonicalWorkspaceRoot).path + val projectName = "${project.groupId}:${project.artifactId}" + val projectType = determineProjectType(project.packaging) + val pathCalculationTime = System.currentTimeMillis() - pathCalculationStart + log.info("Path calculation took ${pathCalculationTime}ms for project: ${project.artifactId} ($root)") + + // Create Nx project configuration + val configCreationStart = System.currentTimeMillis() + val nxProject = objectMapper.createObjectNode() + nxProject.put("name", projectName) + nxProject.put("root", root) + nxProject.put("projectType", projectType) + nxProject.put("sourceRoot", "${root}/src/main/java") + val configCreationTime = System.currentTimeMillis() - configCreationStart + log.info("Basic config creation took ${configCreationTime}ms for project: ${project.artifactId}") + + val targetAnalysisStart = System.currentTimeMillis() + val (nxTargets, targetGroups) = nxTargetFactory.createNxTargets(mavenCommand, project) + val targetAnalysisTime = System.currentTimeMillis() - targetAnalysisStart + log.info("Target analysis took ${targetAnalysisTime}ms for project: ${project.artifactId}") + + nxProject.set("targets", nxTargets) + + // Project metadata including target groups + val metadataStart = System.currentTimeMillis() + val projectMetadata = objectMapper.createObjectNode() + projectMetadata.put("targetGroups", targetGroups) + nxProject.put("metadata", projectMetadata) + + // Tags + val tags = objectMapper.createArrayNode() + tags.add("maven:${project.groupId}") + tags.add("maven:${project.packaging}") + nxProject.put("tags", tags) + val metadataTime = System.currentTimeMillis() - metadataStart + log.info("Metadata and tags creation took ${metadataTime}ms for project: ${project.artifactId}") + + val totalTime = System.currentTimeMillis() - startTime + log.info("Analyzed project: ${project.artifactId} at $root in ${totalTime}ms") + + + val dependencies = project.dependencies.map { dependency -> + NxDependency( + NxDependencyType.Static, + projectName, + "${dependency.groupId}:${dependency.artifactId}", + project.file + ) + }.toMutableList() + + if (project.parent != null) { + dependencies.add( + NxDependency( + NxDependencyType.Static, + projectName, + "${project.parent.groupId}:${project.parent.artifactId}", + project.file + ) + ) + } + + val dependenciesJson = dependencies.map { nxDependency -> + val dependency = objectMapper.createObjectNode() + + dependency.put("type", nxDependency.type.name.lowercase()) + dependency.put("source", nxDependency.source) + dependency.put("target", nxDependency.target) + dependency.put("sourceFile", nxDependency.sourceFile.canonicalFile.relativeTo(canonicalWorkspaceRoot).path) + dependency + } + + return ProjectAnalysis(project.file, root, nxProject, dependenciesJson) + } + + private fun determineProjectType(packaging: String): String { + return when (packaging.lowercase()) { + "pom" -> "library" + "jar", "war", "ear" -> "application" + "maven-plugin" -> "library" + else -> "library" + } + } +} + +data class ProjectAnalysis(val pomFile: File, val root: String, val project: JsonNode, val dependencies: List) + +data class NxDependency(val type: NxDependencyType, val source: String, val target: String, val sourceFile: File) + +enum class NxDependencyType { + Implicit, Static, Dynamic +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzerMojo.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzerMojo.kt new file mode 100644 index 0000000000000..2bf594db9625a --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/NxProjectAnalyzerMojo.kt @@ -0,0 +1,229 @@ +package dev.nx.maven + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import dev.nx.maven.targets.NxTargetFactory +import dev.nx.maven.targets.TestClassDiscovery +import dev.nx.maven.utils.MavenCommandResolver +import dev.nx.maven.utils.MavenExpressionResolver +import dev.nx.maven.utils.MojoAnalyzer +import dev.nx.maven.utils.PathFormatter +import org.apache.maven.execution.MavenSession +import org.apache.maven.lifecycle.DefaultLifecycles +import org.apache.maven.plugin.AbstractMojo +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugins.annotations.* +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Maven plugin to analyze project structure and generate JSON for Nx integration + */ +@Mojo( + name = "analyze", + aggregator = true, + requiresDependencyResolution = ResolutionScope.NONE +) +class NxProjectAnalyzerMojo : AbstractMojo() { + + private val log: Logger = LoggerFactory.getLogger(NxProjectAnalyzerMojo::class.java) + + @Parameter(defaultValue = "\${session}", readonly = true, required = true) + private lateinit var session: MavenSession + + @Component + private lateinit var pluginManager: org.apache.maven.plugin.MavenPluginManager + + @Component + private lateinit var lifecycles: DefaultLifecycles + + @Parameter(property = "outputFile", defaultValue = "nx-maven-projects.json") + private lateinit var outputFile: String + + @Parameter(property = "workspaceRoot", defaultValue = "\${session.executionRootDirectory}") + private lateinit var workspaceRoot: File + + private val objectMapper = ObjectMapper() + + @Throws(MojoExecutionException::class) + override fun execute() { + log.info("Analyzing Maven projects using optimized two-tier approach...") + log.info("Parameters: outputFile='$outputFile', workspaceRoot='$workspaceRoot'") + + // Canonicalize workspace root in place to resolve symlinks (e.g., /tmp -> /private/tmp on macOS) + workspaceRoot = workspaceRoot.canonicalFile + log.info("Canonical workspace root: $workspaceRoot") + + try { + val allProjects = session.allProjects + log.info("Found ${allProjects.size} Maven projects") + + // Step 1: Execute per-project analysis for all projects (in-memory) + log.info("Step 1: Running optimized per-project analysis...") + val inMemoryAnalyses = executePerProjectAnalysisInMemory(allProjects) + + // Step 2: Write project analyses to output file + log.info("Step 2: Writing project analyses to output file...") + writeProjectAnalysesToFile(inMemoryAnalyses) + + log.info("Optimized two-tier analysis completed successfully") + + } catch (e: Exception) { + throw MojoExecutionException("Failed to execute optimized two-tier Maven analysis", e) + } + } + + private fun executePerProjectAnalysisInMemory( + allProjects: List + ): List { + + val gitIgnoreClassifier = GitIgnoreClassifier(workspaceRoot) + val startTime = System.currentTimeMillis() + log.info("Creating shared component instances for optimized analysis...") + + // Create deeply shared components for maximum caching efficiency + val sharedExpressionResolver = MavenExpressionResolver(session) + + // Create shared component instances ONCE for all projects (major optimization) + + val pathFormatter = PathFormatter() + val mojoAnalyzer = MojoAnalyzer(sharedExpressionResolver, pathFormatter, gitIgnoreClassifier) + + val sharedTestClassDiscovery = TestClassDiscovery() + + val sharedLifecycleAnalyzer = NxTargetFactory( + lifecycles, + objectMapper, + sharedTestClassDiscovery, + pluginManager, + session, + mojoAnalyzer, + pathFormatter, + gitIgnoreClassifier + ) + + // Resolve Maven command once for all projects + val mavenCommandStart = System.currentTimeMillis() + val mavenCommand = MavenCommandResolver.getMavenCommand(workspaceRoot) + val mavenCommandTime = System.currentTimeMillis() - mavenCommandStart + log.info("Maven command resolved to '$mavenCommand' in ${mavenCommandTime}ms") + + val setupTime = System.currentTimeMillis() - startTime + log.info("Shared components created in ${setupTime}ms, analyzing ${allProjects.size} projects...") + + val projectStartTime = System.currentTimeMillis() + + // Process projects in parallel with separate analyzer instances + val results = allProjects.parallelStream().map { mavenProject -> + try { + log.info("Analyzing project: ${mavenProject.artifactId}") + + // Create separate analyzer instance for each project (thread-safe) + val singleAnalyzer = NxProjectAnalyzer( + mavenProject, + workspaceRoot, + sharedLifecycleAnalyzer, + mavenCommand + ) + + // Get Nx config for project + val nxConfig = singleAnalyzer.analyze() + + Result.success(nxConfig) + + } catch (e: Exception) { + Result.failure(e) + } + }.collect(java.util.stream.Collectors.toList()) + + val errors = results.filter { it.isFailure } + + if (errors.isNotEmpty()) { + errors.forEach { error -> + log.error("Failed to analyze project", error.exceptionOrNull()) + } + + throw MojoExecutionException("Failed to analyze ${errors.size} of ${allProjects.size} projects. See errors above.") + } + + val inMemoryAnalyses = results.map { it.getOrThrow() } + + val totalTime = System.currentTimeMillis() - startTime + val analysisTime = System.currentTimeMillis() - projectStartTime + log.info("Completed in-memory analysis of ${allProjects.size} projects in ${totalTime}ms (setup: ${setupTime}ms, analysis: ${analysisTime}ms)") + + return inMemoryAnalyses + } + + private fun writeProjectAnalysesToFile(inMemoryAnalyses: List) { + val outputPath = if (outputFile.startsWith("/")) { + File(outputFile) + } else { + File(workspaceRoot, outputFile) + } + + // Ensure parent directory exists + outputPath.parentFile?.mkdirs() + + // Create JSON structure with both project analyses and createNodesResults + val rootNode = objectMapper.createObjectNode() + val projectsNode = objectMapper.createObjectNode() + + // Skip project analyses section - all data is in createNodesResults + rootNode.set("projects", projectsNode) + + // Generate createNodesResults for Nx plugin consumption + val createNodesResults = generateCreateNodesResults(inMemoryAnalyses) + rootNode.set("createNodesResults", createNodesResults) + + + val createDependenciesResults = generateCreateDependenciesResults(inMemoryAnalyses) + rootNode.set("createDependenciesResults", createDependenciesResults) + + // Add metadata + rootNode.put("totalProjects", inMemoryAnalyses.size) + rootNode.put("workspaceRoot", workspaceRoot.absolutePath) + rootNode.put("analysisMethod", "optimized-parallel") + rootNode.put("analyzedProjects", inMemoryAnalyses.size) + + objectMapper.writerWithDefaultPrettyPrinter().writeValue(outputPath, rootNode) + log.info("Generated project analyses with ${inMemoryAnalyses.size} projects: ${outputPath.absolutePath}") + } + + private fun generateCreateDependenciesResults(projectAnalyses: List): ArrayNode { + val result = objectMapper.createArrayNode() + projectAnalyses.forEach { analysis -> + analysis.dependencies.forEach { dependency -> result.add(dependency) } + } + return result + } + + private fun generateCreateNodesResults(inMemoryAnalyses: List): ArrayNode { + val createNodesResults = objectMapper.createArrayNode() + + inMemoryAnalyses.forEach { analysis -> + val resultTuple = objectMapper.createArrayNode() + resultTuple.add(analysis.pomFile.canonicalFile.relativeTo(workspaceRoot).path) // Root path (workspace root) + + // Group projects by root directory (for now, assume all projects are at workspace root) + val projects = objectMapper.createObjectNode() + + val root = analysis.root + val project = analysis.project + + val projectsWrapper = objectMapper.createObjectNode() + projects.set(root, project) + projectsWrapper.set("projects", projects) + resultTuple.add(projectsWrapper) + + createNodesResults.add(resultTuple) + } + + return createNodesResults + } + + +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/BuildState.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/BuildState.kt new file mode 100644 index 0000000000000..f61d3ce8370fd --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/BuildState.kt @@ -0,0 +1,33 @@ +package dev.nx.maven.buildstate + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Data class representing the build state of a Maven project + */ +data class BuildState @JsonCreator constructor( + @JsonProperty("compileSourceRoots") val compileSourceRoots: Set, + @JsonProperty("testCompileSourceRoots") val testCompileSourceRoots: Set, + @JsonProperty("resources") val resources: Set = emptySet(), + @JsonProperty("testResources") val testResources: Set = emptySet(), + @JsonProperty("outputDirectory") val outputDirectory: String? = null, + @JsonProperty("testOutputDirectory") val testOutputDirectory: String? = null, + @JsonProperty("compileClasspath") val compileClasspath: Set = emptySet(), + @JsonProperty("testClasspath") val testClasspath: Set = emptySet(), + @JsonProperty("mainArtifact") val mainArtifact: ArtifactInfo?, + @JsonProperty("attachedArtifacts") val attachedArtifacts: List, + @JsonProperty("outputTimestamp") val outputTimestamp: String? = null +) + +/** + * Data class representing artifact information + */ +data class ArtifactInfo @JsonCreator constructor( + @JsonProperty("file") val file: String, + @JsonProperty("type") val type: String, + @JsonProperty("classifier") val classifier: String?, + @JsonProperty("groupId") val groupId: String, + @JsonProperty("artifactId") val artifactId: String, + @JsonProperty("version") val version: String +) diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateApplyMojo.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateApplyMojo.kt new file mode 100644 index 0000000000000..dfe69fc5ca048 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateApplyMojo.kt @@ -0,0 +1,162 @@ +package dev.nx.maven.buildstate + +import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.maven.execution.MavenSession +import org.apache.maven.model.Resource +import org.apache.maven.plugin.AbstractMojo +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugins.annotations.* +import org.apache.maven.project.MavenProject +import org.apache.maven.project.MavenProjectHelper +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Maven plugin to reapply previously recorded build state (source roots and artifacts) + */ +@Mojo( + name = "apply", + requiresProject = true, + threadSafe = false +) +class NxBuildStateApplyMojo : AbstractMojo() { + + companion object { + private const val BUILD_STATE_FILE = "nx-build-state.json" + } + + private val log: Logger = LoggerFactory.getLogger(NxBuildStateApplyMojo::class.java) + private val objectMapper = ObjectMapper() + + @Parameter(defaultValue = "\${project}", readonly = true, required = true) + private lateinit var project: MavenProject + + @Parameter(defaultValue = "\${session}", readonly = true, required = true) + private lateinit var session: MavenSession + + @Component + private lateinit var projectHelper: MavenProjectHelper + + @Throws(MojoExecutionException::class) + override fun execute() { + val startTime = System.currentTimeMillis() + try { + applyAllBuildStates() + val duration = System.currentTimeMillis() - startTime + log.info("Build state application completed (took ${duration}ms)") + } catch (e: Exception) { + throw MojoExecutionException("Failed to reapply build state", e) + } + } + + private fun addIfExists(path: String, action: () -> Unit) { + if (File(path).isDirectory) action() else log.warn("Directory not found: $path") + } + + private fun addResourceIfExists(path: String, action: (Resource) -> Unit) { + if (File(path).isDirectory) { + val resource = Resource().apply { directory = path } + action(resource) + } else { + log.warn("Resource directory not found: $path") + } + } + + private fun applyAllBuildStates() { + // Check all projects in the build - those with build state files will be applied + val projectsToApply = session.allProjects.mapNotNull { depProject -> + val stateFile = File(depProject.build.directory, BUILD_STATE_FILE) + if (stateFile.exists()) depProject to stateFile else null + } + + if (projectsToApply.isNotEmpty()) { + log.info("Applying build state to ${projectsToApply.size} projects...") + projectsToApply.parallelStream().forEach { (depProject, stateFile) -> + try { + val buildState = objectMapper.readValue(stateFile, BuildState::class.java) + applyBuildStateToProject(depProject, buildState) + } catch (e: Exception) { + log.warn("Failed to apply build state to ${depProject.artifactId}: ${e.message}") + } + } + } else { + log.info("No build state files found in dependency projects") + } + } + + private fun applyBuildStateToProject(targetProject: MavenProject, buildState: BuildState) { + log.info("Applying build state for project: ${targetProject.artifactId}") + + // If this is the current project, apply source roots, resources, and output directories + if (targetProject.artifactId == project.artifactId) { + // Convert relative paths to absolute paths and apply source roots + val compileSourceRoots = PathUtils.toAbsolutePaths(buildState.compileSourceRoots, targetProject.basedir, log) + compileSourceRoots.forEach { addIfExists(it) { targetProject.addCompileSourceRoot(it) } } + + val testCompileSourceRoots = PathUtils.toAbsolutePaths(buildState.testCompileSourceRoots, targetProject.basedir, log) + testCompileSourceRoots.forEach { addIfExists(it) { targetProject.addTestCompileSourceRoot(it) } } + + // Convert relative paths to absolute paths and apply resources + val resources = PathUtils.toAbsolutePaths(buildState.resources, targetProject.basedir, log) + resources.forEach { addResourceIfExists(it, targetProject::addResource) } + + val testResources = PathUtils.toAbsolutePaths(buildState.testResources, targetProject.basedir, log) + testResources.forEach { addResourceIfExists(it, targetProject::addTestResource) } + + // Convert relative paths to absolute paths and apply output directories + buildState.outputDirectory?.let { + val absPath = PathUtils.toAbsolutePath(it, targetProject.basedir, log) + if (File(absPath).isDirectory) targetProject.build.outputDirectory = absPath + } + buildState.testOutputDirectory?.let { + val absPath = PathUtils.toAbsolutePath(it, targetProject.basedir, log) + if (File(absPath).isDirectory) targetProject.build.testOutputDirectory = absPath + } + + // Convert relative paths to absolute paths and apply classpaths + val compileClasspath = PathUtils.toAbsolutePaths(buildState.compileClasspath, targetProject.basedir, log) + targetProject.compileClasspathElements.addAll(compileClasspath) + + val testClasspath = PathUtils.toAbsolutePaths(buildState.testClasspath, targetProject.basedir, log) + targetProject.testClasspathElements.addAll(testClasspath) + } + + // Apply main artifact (only if file exists) + buildState.mainArtifact?.let { applyMainArtifact(targetProject, it) } + + // Apply attached artifacts (only if file exists) + buildState.attachedArtifacts.forEach { applyAttachedArtifact(targetProject, it) } + + // Apply outputTimestamp + buildState.outputTimestamp?.let { + targetProject.properties.setProperty("project.build.outputTimestamp", it) + } + } + + private fun applyMainArtifact(targetProject: MavenProject, artifact: ArtifactInfo) { + val absPath = PathUtils.toAbsolutePath(artifact.file, targetProject.basedir, log) + val file = File(absPath) + if (file.exists()) { + log.info("Applying main artifact: ${file.absolutePath} to ${targetProject.artifactId}") + targetProject.artifact.file = file + } else { + log.warn("Main artifact file does not exist, skipping: ${file.absolutePath}") + } + } + + private fun applyAttachedArtifact(targetProject: MavenProject, artifact: ArtifactInfo) { + val absPath = PathUtils.toAbsolutePath(artifact.file, targetProject.basedir, log) + val file = File(absPath) + if (file.exists()) { + log.info("Applying attached artifact: ${file.absolutePath} to ${targetProject.artifactId}") + val classifier = artifact.classifier + when { + classifier.isNullOrEmpty() -> projectHelper.attachArtifact(targetProject, artifact.type, file) + else -> projectHelper.attachArtifact(targetProject, artifact.type, classifier, file) + } + } else { + log.warn("Attached artifact file does not exist, skipping: ${file.absolutePath}") + } + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateRecordMojo.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateRecordMojo.kt new file mode 100644 index 0000000000000..044f8c077a782 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/NxBuildStateRecordMojo.kt @@ -0,0 +1,183 @@ +package dev.nx.maven.buildstate + +import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.maven.artifact.Artifact +import org.apache.maven.model.Resource +import org.apache.maven.plugin.AbstractMojo +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugins.annotations.* +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Maven plugin to record the current build state (source roots and artifacts) + */ +@Mojo( + name = "record", + requiresProject = true, + threadSafe = false +) +class NxBuildStateRecordMojo : AbstractMojo() { + + companion object { + private const val BUILD_STATE_FILE = "nx-build-state.json" + } + + private val log: Logger = LoggerFactory.getLogger(NxBuildStateRecordMojo::class.java) + private val objectMapper = ObjectMapper() + + @Parameter(defaultValue = "\${project}", readonly = true, required = true) + private lateinit var project: MavenProject + + @Parameter(property = "outputFile", defaultValue = "\${project.build.directory}/$BUILD_STATE_FILE", readonly = true) + private lateinit var outputFile: File + + @Throws(MojoExecutionException::class) + override fun execute() { + val startTime = System.currentTimeMillis() + try { + log.info("Recording build state for project: ${project.artifactId}") + + // Capture compile source roots + val compileSourceRootsAbsolute = project.compileSourceRoots.toSet() + val compileSourceRoots = PathUtils.toRelativePaths(compileSourceRootsAbsolute, project.basedir, log) + log.info("Captured ${compileSourceRoots.size} compile source roots") + + // Capture test compile source roots + val testCompileSourceRootsAbsolute = project.testCompileSourceRoots.toSet() + val testCompileSourceRoots = PathUtils.toRelativePaths(testCompileSourceRootsAbsolute, project.basedir, log) + log.info("Captured ${testCompileSourceRoots.size} test compile source roots") + + // Capture resources + val resourcesAbsolute = project.resources.map { (it as Resource).directory }.filter { it != null }.toSet() + val resources = PathUtils.toRelativePaths(resourcesAbsolute, project.basedir, log) + log.info("Captured ${resources.size} resource directories") + + // Capture test resources + val testResourcesAbsolute = project.testResources.map { (it as Resource).directory }.filter { it != null }.toSet() + val testResources = PathUtils.toRelativePaths(testResourcesAbsolute, project.basedir, log) + log.info("Captured ${testResources.size} test resource directories") + + // Capture output directories and convert to relative paths + val outputDirectoryAbsolute = project.build.outputDirectory + val outputDirectory = PathUtils.toRelativePath(outputDirectoryAbsolute, project.basedir, log) + val testOutputDirectoryAbsolute = project.build.testOutputDirectory + val testOutputDirectory = PathUtils.toRelativePath(testOutputDirectoryAbsolute, project.basedir, log) + log.info("Captured output directory: $outputDirectory") + log.info("Captured test output directory: $testOutputDirectory") + + // Capture compile classpath and convert to relative paths + val compileClasspath = captureClasspath("compile", project.compileClasspathElements) + + // Capture test classpath and convert to relative paths + val testClasspath = captureClasspath("test", project.testClasspathElements) + + // Capture main artifact (only if file exists) + val mainArtifact = captureMainArtifact() + + // Capture attached artifacts (only if file exists) + val attachedArtifacts = captureAttachedArtifacts() + + // Capture project.build.outputTimestamp for reproducible builds + val outputTimestamp = project.properties.getProperty("project.build.outputTimestamp") + if (outputTimestamp != null) { + log.info("Captured outputTimestamp: $outputTimestamp") + } + + // Create build state + val buildState = BuildState( + compileSourceRoots = compileSourceRoots, + testCompileSourceRoots = testCompileSourceRoots, + resources = resources, + testResources = testResources, + outputDirectory = outputDirectory, + testOutputDirectory = testOutputDirectory, + compileClasspath = compileClasspath, + testClasspath = testClasspath, + mainArtifact = mainArtifact, + attachedArtifacts = attachedArtifacts, + outputTimestamp = outputTimestamp + ) + + log.info("Recorded build state - Compile source roots: ${buildState.compileSourceRoots.size}, " + + "Test source roots: ${buildState.testCompileSourceRoots.size}, " + + "Resources: ${buildState.resources.size}, " + + "Test resources: ${buildState.testResources.size}, " + + "Output directory: ${buildState.outputDirectory}, " + + "Test output directory: ${buildState.testOutputDirectory}, " + + "Compile classpath: ${buildState.compileClasspath.size}, " + + "Test classpath: ${buildState.testClasspath.size}, " + + "Attached artifacts: ${buildState.attachedArtifacts.size}") + + // Ensure output directory exists + outputFile.parentFile?.mkdirs() + + // Write to JSON file + objectMapper.writerWithDefaultPrettyPrinter().writeValue(outputFile, buildState) + + val duration = System.currentTimeMillis() - startTime + log.info("Build state recorded to: ${outputFile.absolutePath} (took ${duration}ms)") + + } catch (e: Exception) { + throw MojoExecutionException("Failed to record build state", e) + } + } + + private fun captureClasspath(classpathType: String, classpathElements: List): Set { + val absolutePaths = try { + classpathElements.toSet() + } catch (e: Exception) { + log.warn("Failed to capture $classpathType classpath: ${e.message}") + emptySet() + } + val relativePaths = PathUtils.toRelativePaths(absolutePaths, project.basedir, log) + log.info("Captured ${relativePaths.size} $classpathType classpath elements") + return relativePaths + } + + private fun captureMainArtifact(): ArtifactInfo? { + val artifactFile = project.artifact?.file + if (artifactFile != null && artifactFile.exists()) { + val info = ArtifactInfo( + file = PathUtils.toRelativePath(artifactFile.absolutePath, project.basedir, log), + type = project.artifact.type, + classifier = project.artifact.classifier, + groupId = project.artifact.groupId, + artifactId = project.artifact.artifactId, + version = project.artifact.version + ) + log.info("Captured main artifact: ${info.file}") + return info + } else if (artifactFile != null) { + log.warn("Main artifact file does not exist: ${artifactFile.absolutePath}") + } + return null + } + + private fun captureAttachedArtifacts(): List { + val artifacts = project.attachedArtifacts.mapNotNull { artifact: Artifact -> + when { + artifact.file != null && artifact.file.exists() -> ArtifactInfo( + file = PathUtils.toRelativePath(artifact.file.absolutePath, project.basedir, log), + type = artifact.type, + classifier = artifact.classifier, + groupId = artifact.groupId, + artifactId = artifact.artifactId, + version = artifact.version + ) + artifact.file == null -> { + log.warn("Attached artifact has no file: ${artifact.groupId}:${artifact.artifactId}:${artifact.version}") + null + } + else -> { + log.warn("Attached artifact file does not exist: ${artifact.file.absolutePath}") + null + } + } + } + log.info("Captured ${artifacts.size} attached artifacts") + return artifacts + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/PathUtils.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/PathUtils.kt new file mode 100644 index 0000000000000..46da142e6ef25 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/buildstate/PathUtils.kt @@ -0,0 +1,66 @@ +package dev.nx.maven.buildstate + +import org.slf4j.Logger +import java.io.File +import java.nio.file.Paths + +/** + * Utility class for converting between absolute and relative paths + */ +object PathUtils { + + /** + * Convert an absolute path to a relative path from the project root + * + * @param absolutePath The absolute path to convert + * @param projectRoot The project root directory + * @param logger Optional logger for warnings + * @return The relative path from project root, or the original path if conversion fails + */ + fun toRelativePath(absolutePath: String, projectRoot: File, logger: Logger? = null): String { + return try { + val absPath = File(absolutePath).canonicalPath + val rootPath = projectRoot.canonicalPath + Paths.get(rootPath).relativize(Paths.get(absPath)).toString() + } catch (_: Exception) { + logger?.warn("Failed to convert absolute path to relative: $absolutePath, using absolute path") + absolutePath + } + } + + /** + * Convert a relative path to an absolute path based on the project root + * + * @param pathString The path string (could be relative or absolute) + * @param projectRoot The project root directory + * @param logger Optional logger for warnings + * @return The absolute path + */ + fun toAbsolutePath(pathString: String, projectRoot: File, logger: Logger? = null): String { + return try { + val path = File(pathString) + if (path.isAbsolute) { + path.canonicalPath + } else { + File(projectRoot, pathString).canonicalPath + } + } catch (_: Exception) { + logger?.warn("Failed to convert relative path to absolute: $pathString, using as-is") + pathString + } + } + + /** + * Convert a set of absolute paths to relative paths + */ + fun toRelativePaths(absolutePaths: Set, projectRoot: File, logger: Logger? = null): Set { + return absolutePaths.map { toRelativePath(it, projectRoot, logger) }.toSet() + } + + /** + * Convert a set of paths (potentially relative) to absolute paths + */ + fun toAbsolutePaths(paths: Set, projectRoot: File, logger: Logger? = null): Set { + return paths.map { toAbsolutePath(it, projectRoot, logger) }.toSet() + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/cache/CacheConfig.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/cache/CacheConfig.kt new file mode 100644 index 0000000000000..56b49a813e133 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/cache/CacheConfig.kt @@ -0,0 +1,210 @@ +package dev.nx.maven.cache + +data class PathPattern( + val path: String, + val recursive: Boolean = false +) + +data class Parameter(val name: String, val glob: String?) + +data class MojoConfig( + val inputProperties: Set? = null, + val inputParameters: Set? = null, + val outputParameters: Set? = null +) + +/** + * Simple data types for managing Maven plugin cache configuration. + * These are custom data structures that replace build cache extension dependencies. + */ +data class CacheConfig( + val defaultInputs: List = emptyList(), + val defaultOutputs: List = emptyList(), + val configurations: Map = emptyMap(), + val nonCacheable: Set = emptySet(), + val continuous: Set = emptySet() +) { + companion object { + val DEFAULT = CacheConfig( + defaultInputs = listOf( + PathPattern("src/main/**/*", recursive = true), + PathPattern("src/test/**/*", recursive = true), + PathPattern("pom.xml"), + PathPattern("*.properties") + ), + defaultOutputs = listOf( + PathPattern("target") + ), + configurations = mapOf( + "maven-checkstyle-plugin:check" to MojoConfig( + inputParameters = setOf( + Parameter("configLocation", null), + Parameter("headerLocation", null), + Parameter("propertiesLocation", null), + Parameter("rulesFiles", null), + Parameter("siteDirectory", null), + Parameter("sourceDirectories", null), + Parameter("suppressionsLocation", null), + Parameter("testSourceDirectories", null), + ), + outputParameters = setOf( + Parameter("cacheFile", null), + Parameter("outputDirectory", null), + Parameter("outputFile", null), + ) + ), + "maven-checkstyle-plugin:checkstyle" to MojoConfig( + inputParameters = setOf( + Parameter("configLocation", null), + Parameter("headerLocation", null), + Parameter("propertiesLocation", null), + Parameter("rulesFiles", null), + Parameter("siteDirectory", null), + Parameter("sourceDirectories", null), + Parameter("suppressionsLocation", null), + Parameter("testSourceDirectories", null), + ), + outputParameters = setOf( + Parameter("cacheFile", null), + Parameter("outputDirectory", null), + Parameter("outputFile", null), + ) + ), + "maven-compiler-plugin:compile" to MojoConfig( + inputParameters = setOf( + Parameter("compileSourceRoots", "**/*.java"), + // Include generated sources by convention + Parameter("buildDirectory", "generated-sources/**/*.java"), + ), + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "maven-compiler-plugin:testCompile" to MojoConfig( + inputParameters = setOf( + Parameter("compileSourceRoots", "**/*.java"), + ), + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "maven-resources-plugin:resources" to MojoConfig( + inputProperties = setOf("project.build.resources"), + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "maven-resources-plugin:testResources" to MojoConfig( + inputParameters = setOf( + Parameter("resources", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "maven-surefire-plugin:test" to MojoConfig( + inputParameters = setOf( + Parameter("classesDirectory", null), + Parameter("testClassesDirectory", null), + Parameter("suiteXmlFiles", null), + ), + outputParameters = setOf( + Parameter("reportsDirectory", null), + ) + ), + "maven-failsafe-plugin:integration-test" to MojoConfig( + inputParameters = setOf( + Parameter("classesDirectory", null), + Parameter("testClassesDirectory", null), + Parameter("testSourceDirectory", null), + Parameter("suiteXmlFiles", null), + ), + outputParameters = setOf( + Parameter("summaryFile", null), + ) + ), + "maven-failsafe-plugin:verify" to MojoConfig( + inputParameters = setOf( + Parameter("summaryFile", null), + Parameter("summaryFiles", null), + Parameter("testClassesDirectory", null), + ), + outputParameters = emptySet() + ), + "maven-jar-plugin:jar" to MojoConfig( + inputParameters = setOf( + Parameter("classesDirectory", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", "*.jar"), + ) + ), + "maven-jar-plugin:test-jar" to MojoConfig( + inputParameters = setOf( + Parameter("testClassesDirectory", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", "*.jar"), + ) + ), + "maven-war-plugin:war" to MojoConfig( + inputParameters = setOf( + Parameter("warSourceDirectory", null), + Parameter("webResources", null), + Parameter("webXml", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", "*.war"), + Parameter("webappDirectory", null), + Parameter("workDirectory", null), + ) + ), + "spring-boot-maven-plugin:run" to MojoConfig( + inputParameters = setOf( + Parameter("testClassesDirectory", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", "*.jar"), + ) + ), + "spring-boot-maven-plugin:repackage" to MojoConfig( + inputParameters = setOf( + Parameter("classesDirectory", null), + Parameter("testClassesDirectory", null), + Parameter("embeddedLaunchScript", null), + ), + outputParameters = setOf( + Parameter("outputDirectory", "*.jar"), + ) + ), + "modello-maven-plugin:velocity" to MojoConfig( + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "modello-maven-plugin:xdoc" to MojoConfig( + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ), + "modello-maven-plugin:xsd" to MojoConfig( + outputParameters = setOf( + Parameter("outputDirectory", null), + ) + ) + ), + nonCacheable = setOf( + "maven-clean-plugin:clean", + "maven-deploy-plugin:deploy", + "maven-site-plugin:site", + "maven-install-plugin:install", + "bb-sdk-codegen:deploy-local", + "spring-boot-maven-plugin:run" + ), + continuous = setOf( + "spring-boot-maven-plugin:run", + ) + ) + } +} + diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTarget.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTarget.kt new file mode 100644 index 0000000000000..862e9df468b98 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTarget.kt @@ -0,0 +1,31 @@ +package dev.nx.maven.targets + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode + +data class NxTarget( + val executor: String, + val options: ObjectNode?, + val cache: Boolean, + val parallelism: Boolean, + var dependsOn: ArrayNode? = null, + var outputs: ArrayNode? = null, + var inputs: ArrayNode? = null +) { + fun toJSON(objectMapper: ObjectMapper): ObjectNode { + val node = objectMapper.createObjectNode() + node.put("executor", executor) + if (options != null) { + node.set("options", options) + } + node.put("cache", cache) + node.put("parallelism", parallelism) + + dependsOn?.let { node.set("dependsOn", it) } + outputs?.let { node.set("outputs", it) } + inputs?.let { node.set("inputs", it) } + + return node + } +} \ No newline at end of file diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTargetFactory.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTargetFactory.kt new file mode 100644 index 0000000000000..71aa74209226a --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/NxTargetFactory.kt @@ -0,0 +1,682 @@ +package dev.nx.maven.targets + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import dev.nx.maven.GitIgnoreClassifier +import dev.nx.maven.utils.MojoAnalyzer +import dev.nx.maven.utils.PathFormatter +import org.apache.maven.execution.MavenSession +import org.apache.maven.lifecycle.DefaultLifecycles +import org.apache.maven.model.Plugin +import org.apache.maven.model.PluginExecution +import org.apache.maven.plugin.MavenPluginManager +import org.apache.maven.plugin.descriptor.MojoDescriptor +import org.apache.maven.plugin.descriptor.PluginDescriptor +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +private const val APPLY_GOAL = "dev.nx.maven:nx-maven-plugin:apply" + +private const val RECORD_GOAL = "dev.nx.maven:nx-maven-plugin:record" + + +data class GoalDescriptor( + val pluginDescriptor: PluginDescriptor, + val mojoDescriptor: MojoDescriptor, + val goal: String, + val goalSpecifier: String, + val executionPriority: Int = 50, // Default priority - Maven uses 50 as default + val executionId: String = "default" +) + +/** + * Collects lifecycle and plugin information directly from Maven APIs + */ +class NxTargetFactory( + private val lifecycles: DefaultLifecycles, + private val objectMapper: ObjectMapper, + private val testClassDiscovery: TestClassDiscovery, + private val pluginManager: MavenPluginManager, + private val session: MavenSession, + private val mojoAnalyzer: MojoAnalyzer, + private val pathFormatter: PathFormatter, + private val gitIgnoreClassifier: GitIgnoreClassifier, +) { + private val log: Logger = LoggerFactory.getLogger(NxTargetFactory::class.java) + + + fun createNxTargets( + mavenCommand: String, project: MavenProject + ): Pair { + val nxTargets = objectMapper.createObjectNode() + val targetGroups = mutableMapOf>() + + val phaseDependsOn = mutableMapOf>() + val plugins = getExecutablePlugins(project) + + // Collect all goals by phase from plugin executions + val phaseGoals = collectGoalsByPhase(plugins, project) + + val phaseTargets = mutableMapOf() + val ciPhaseTargets = mutableMapOf() + val ciPhasesWithGoals = mutableSetOf() // Track which CI phases have goals + + // Create phase targets from lifecycle (all phases get targets, either with goals or as noop) + // CI phase targets are only created if they have goals or are "verify" phase + processLifecyclePhases( + lifecycles.lifeCycles, + phaseGoals, + project, + mavenCommand, + phaseTargets, + ciPhaseTargets, + phaseDependsOn, + ciPhasesWithGoals + ) + + // Create individual goal targets for granular execution + createIndividualGoalTargets(plugins, project, mavenCommand, nxTargets) + + val mavenPhasesGroup = mutableListOf() + phaseTargets.forEach { (phase, target) -> + nxTargets.set(phase, target.toJSON(objectMapper)) + mavenPhasesGroup.add(phase) + } + targetGroups["Phases"] = mavenPhasesGroup + + val ciPhasesGroup = mutableListOf() + ciPhaseTargets.forEach { (phase, target) -> + nxTargets.set(phase, target.toJSON(objectMapper)) + ciPhasesGroup.add(phase) + } + targetGroups["CI Phases"] = ciPhasesGroup + + if (phaseGoals.contains("test")) { + val atomizedTestTargets = generateAtomizedTestTargets( + project, + mavenCommand, + ciPhaseTargets["test-ci"]!!, + phaseGoals["test"]!!, + phaseDependsOn["test-ci"]!! + ) + + atomizedTestTargets.forEach { (goal, target) -> + nxTargets.set(goal, target.toJSON(objectMapper)) + } + targetGroups["Test CI"] = atomizedTestTargets.keys.toList() + } else { + log.info("No test goals found for project ${project.artifactId}, skipping atomized test target generation") + } + + val targetGroupsJson = buildTargetGroupsJson(targetGroups) + return Pair(nxTargets, targetGroupsJson) + } + + private fun createPhaseTarget( + project: MavenProject, phase: String, mavenCommand: String, goals: List + ): NxTarget { + // Sort goals by priority (lower numbers execute first) + val sortedGoals = goals.sortedWith(compareBy { it.executionPriority }.thenBy { it.executionId }) + + // Inline analysis logic from PhaseAnalyzer + val plugins = project.build.plugins + var isThreadSafe = true + var isCacheable = true + val inputs = objectMapper.createArrayNode() + val outputs = mutableSetOf() + + val analyses = plugins + .flatMap { plugin -> + val pluginDescriptor = runCatching { getPluginDescriptor(plugin, project) } + .getOrElse { throwable -> + log.warn( + "Failed to resolve plugin descriptor for ${plugin.groupId}:${plugin.artifactId}: ${throwable.message}" + ) + return@flatMap emptyList() + } + + plugin.executions + .filter { execution -> normalizePhase(execution.phase) == phase } + .flatMap { execution -> + log.info( + "Analyzing ${project.groupId}:${project.artifactId} execution: ${execution.id} -> phase: ${execution.phase}, goals: ${execution.goals}" + ) + + execution.goals.filterNotNull().mapNotNull { goal -> + mojoAnalyzer.analyzeMojo(pluginDescriptor, goal, project) + } + } + } + + // Aggregate analysis results + analyses.forEach { analysis -> + + if (!analysis.isThreadSafe) { + isThreadSafe = false + } + if (!analysis.isCacheable) { + isCacheable = false + } + + analysis.inputs.forEach { input -> inputs.add(input) } + analysis.outputs.forEach { output -> outputs.add(output) } + analysis.dependentTaskOutputInputs.forEach { input -> + val obj = objectMapper.createObjectNode() + obj.put("dependentTasksOutputFiles", input.path) + if (input.transitive) obj.put("transitive", true) + inputs.add(obj) + } + } + + log.info("Phase $phase analysis: thread safe: $isThreadSafe, cacheable: $isCacheable, inputs: $inputs, outputs: $outputs") + + val options = objectMapper.createObjectNode() + + // Build command with goals bundled together + val commandParts = mutableListOf() + commandParts.add(mavenCommand) + + // Add build state apply (all goals get build state management for maximum compatibility) + commandParts.add(APPLY_GOAL) + + // Add all goals for this phase (sorted by priority) + commandParts.addAll(sortedGoals.map { it.goalSpecifier }) + + // Add build state record (all goals except install) + // TODO: install cannot record because it attaches a unique timestamp to artifacts, breaking caching + if (phase !== "install") { + commandParts.add(RECORD_GOAL) + } + + // Add project selection and non-recursive flag + commandParts.add("-pl") + commandParts.add("${project.groupId}:${project.artifactId}") + + // Only add -N flag for Maven 4 + val mavenVersion = session.systemProperties.getProperty("maven.version") ?: "" + if (mavenVersion.startsWith("4")) { + commandParts.add("-N") + } + + commandParts.add("--batch-mode") + + val command = commandParts.joinToString(" ") + options.put("command", command) + + log.info("Created phase target '$phase' with command: $command") + + val target = NxTarget("nx:run-commands", options, isCacheable, isThreadSafe) + + // Copy caching info from analysis + if (isCacheable) { + // Convert inputs to JsonNode array + val inputsArray = objectMapper.createArrayNode() + inputs.forEach { input -> inputsArray.add(input) } + target.inputs = inputsArray + + // Convert outputs to JsonNode array + val outputsArray = objectMapper.createArrayNode() + outputs.forEach { output -> outputsArray.add(output) } + target.outputs = outputsArray + addBuildStateJsonInputsAndOutputs(project, target) + } + + return target + } + + private fun processLifecyclePhases( + lifecycles: List, + phaseGoals: Map>, + project: MavenProject, + mavenCommand: String, + phaseTargets: MutableMap, + ciPhaseTargets: MutableMap, + phaseDependsOn: MutableMap>, + ciPhasesWithGoals: MutableSet + ) { + lifecycles.forEach { lifecycle -> + log.info( + "Analyzing ${lifecycle.phases.size} phases for ${project.artifactId}: ${ + lifecycle.phases.joinToString(", ") + }" + ) + + val hasInstall = lifecycle.phases.contains("install") + val testIndex = lifecycle.phases.indexOf("test") + + // First pass: create regular phase targets + lifecycle.phases.forEachIndexed { index, phase -> + createRegularPhaseTarget( + lifecycle.phases, index, phase, phaseGoals, project, mavenCommand, + phaseTargets, phaseDependsOn, hasInstall + ) + } + + // Second pass: create CI phase targets (depends on first pass completing) + lifecycle.phases.forEachIndexed { index, phase -> + if (testIndex > -1) { + createCiPhaseTarget( + lifecycle.phases, index, phase, phaseGoals, project, mavenCommand, + ciPhaseTargets, phaseDependsOn, ciPhasesWithGoals, hasInstall + ) + } + } + } + } + + private fun createRegularPhaseTarget( + phases: List, + index: Int, + phase: String, + phaseGoals: Map>, + project: MavenProject, + mavenCommand: String, + phaseTargets: MutableMap, + phaseDependsOn: MutableMap>, + hasInstall: Boolean + ) { + val goalsForPhase = phaseGoals[phase] + val hasGoals = goalsForPhase?.isNotEmpty() == true + + // Create target for all phases - either with goals or as noop + val target = if (hasGoals) { + createPhaseTarget(project, phase, mavenCommand, goalsForPhase!!) + } else { + createNoopPhaseTarget(phase) + } + + phaseDependsOn[phase] = mutableListOf() + target.dependsOn = target.dependsOn ?: objectMapper.createArrayNode() + + if (hasInstall) { + target.dependsOn?.add("^install") + phaseDependsOn[phase]?.add("^install") + } + + // Add dependency on immediate previous phase (if exists) + val previousPhase = phases.getOrNull(index - 1) + if (previousPhase != null) { + target.dependsOn?.add(previousPhase) + phaseDependsOn[phase]?.add(previousPhase) + } + + phaseTargets[phase] = target + + if (hasGoals) { + log.info("Created phase target '$phase' with ${goalsForPhase?.size ?: 0} goals") + } else { + log.info("Created noop phase target '$phase' (no goals)") + } + } + + private fun createCiPhaseTarget( + phases: List, + index: Int, + phase: String, + phaseGoals: Map>, + project: MavenProject, + mavenCommand: String, + ciPhaseTargets: MutableMap, + phaseDependsOn: MutableMap>, + ciPhasesWithGoals: MutableSet, + hasInstall: Boolean + ) { + val goalsForPhase = phaseGoals[phase] + val hasGoals = goalsForPhase?.isNotEmpty() == true + val ciPhaseName = "$phase-ci" + + // Test and later phases get a CI counterpart - but only if they have goals + if (!shouldCreateCiPhase(hasGoals, phase)) { + log.info("Skipping noop CI phase target '$ciPhaseName' (no goals)") + return + } + + // Create CI targets for phases with goals, or noop for test/structural phases + val ciTarget = if (hasGoals && phase != "test") { + createPhaseTarget(project, phase, mavenCommand, goalsForPhase!!) + } else { + // Noop for test phase (will be orchestrated by atomized tests) or structural phases + createNoopPhaseTarget(phase) + } + val ciPhaseDependsOn = mutableListOf() + + // Find the nearest previous phase that has a CI target + val previousCiPhase = findPreviousCiPhase(phases, index, ciPhasesWithGoals) + + if (previousCiPhase != null) { + ciPhaseDependsOn.add("$previousCiPhase-ci") + log.info("CI phase '$phase' depends on previous CI phase: '$previousCiPhase'") + } + + if (hasInstall) { + ciPhaseDependsOn.add("^install-ci") + } + + // Initialize dependsOn for all CI targets (for atomized tests or phase dependencies) + ciTarget.dependsOn = ciTarget.dependsOn ?: objectMapper.createArrayNode() + + // Add phase dependencies to all CI targets + ciPhaseDependsOn.forEach { + ciTarget.dependsOn?.add(it) + } + + phaseDependsOn[ciPhaseName] = ciPhaseDependsOn + ciPhaseTargets[ciPhaseName] = ciTarget + // Track all CI phases so the dependency chain is preserved + ciPhasesWithGoals.add(phase) + + if (hasGoals) { + log.info("Created CI phase target '$ciPhaseName' with goals") + } else { + log.info("Created noop CI phase target '$ciPhaseName'") + } + } + + private fun collectGoalsByPhase(plugins: List, project: MavenProject): Map> { + val phaseGoals = mutableMapOf>() + + plugins.forEach { plugin: Plugin -> + val pluginDescriptor = getPluginDescriptor(plugin, project) + val goalPrefix = pluginDescriptor.goalPrefix + + plugin.executions.forEach { execution -> + execution.goals.forEach { goal -> + val mojoDescriptor = pluginDescriptor.getMojo(goal) + val phase = execution.phase ?: mojoDescriptor?.phase + val normalizedPhase = normalizePhase(phase) + + if (normalizedPhase != null) { + val goalSpec = "$goalPrefix:$goal@${execution.id}" + val executionPriority = getExecutionPriority(execution) + + val goalDescriptor = GoalDescriptor( + pluginDescriptor = pluginDescriptor, + mojoDescriptor = mojoDescriptor, + goal = goal, + goalSpecifier = goalSpec, + executionPriority = executionPriority, + executionId = execution.id + ) + + phaseGoals.computeIfAbsent(normalizedPhase) { mutableListOf() }.add(goalDescriptor) + log.info("Added goal $goalSpec to phase $normalizedPhase with priority $executionPriority") + } + } + } + } + + return phaseGoals + } + + private fun createIndividualGoalTargets( + plugins: List, + project: MavenProject, + mavenCommand: String, + nxTargets: ObjectNode + ) { + plugins.forEach { plugin: Plugin -> + val pluginDescriptor = runCatching { getPluginDescriptor(plugin, project) } + .getOrElse { throwable -> + log.warn( + "Failed to resolve plugin descriptor for ${plugin.groupId}:${plugin.artifactId}: ${throwable.message}" + ) + return@forEach + } + val goalPrefix = pluginDescriptor.goalPrefix + + plugin.executions.forEach { execution -> + execution.goals.forEach { goal -> + // Skip build-helper attach-artifact goal as it's not relevant for Nx + if (goalPrefix == "org.codehaus.mojo.build-helper" && goal == "attach-artifact") { + return@forEach + } + + val goalTargetName = "$goalPrefix:$goal@${execution.id}" + val goalTarget = createSimpleGoalTarget( + mavenCommand, + project, + pluginDescriptor, + goalPrefix, + goal, + execution + ) ?: return@forEach + nxTargets.set(goalTargetName, goalTarget.toJSON(objectMapper)) + + log.info("Created individual goal target: $goalTargetName") + } + } + } + } + + private fun findPreviousCiPhase( + lifecycle: List, + index: Int, + ciPhasesWithGoals: Set + ): String? { + for (prevIdx in index - 1 downTo 0) { + val prevPhase = lifecycle.getOrNull(prevIdx) + if (prevPhase != null && ciPhasesWithGoals.contains(prevPhase)) { + return prevPhase + } + } + return null + } + + private fun shouldCreateCiPhase(hasGoals: Boolean, phase: String): Boolean { + return hasGoals || phase == "verify" + } + + private fun buildTargetGroupsJson(targetGroups: Map>): ObjectNode { + val targetGroupsJson = objectMapper.createObjectNode() + targetGroups.forEach { (groupName, targets) -> + val targetsArray = objectMapper.createArrayNode() + targets.forEach { target -> targetsArray.add(target) } + targetGroupsJson.set(groupName, targetsArray) + } + return targetGroupsJson + } + + private fun createNoopPhaseTarget( + phase: String + ): NxTarget { + log.info("Creating noop target for phase '$phase' (no goals)") + return NxTarget("nx:noop", null, cache = true, parallelism = true) + } + + private fun createSimpleGoalTarget( + mavenCommand: String, + project: MavenProject, + pluginDescriptor: PluginDescriptor, + goalPrefix: String, + goalName: String, + execution: PluginExecution + ): NxTarget? { + val options = objectMapper.createObjectNode() + + // Simple command without nx:apply/nx:record + val mavenVersion = session.systemProperties.getProperty("maven.version") ?: "" + val nonRecursiveFlag = if (mavenVersion.startsWith("4")) "-N" else "" + val command = + "$mavenCommand $goalPrefix:$goalName@${execution.id} -pl ${project.groupId}:${project.artifactId} $nonRecursiveFlag --batch-mode".replace(" ", " ") + options.put("command", command) + val analysis = mojoAnalyzer.analyzeMojo(pluginDescriptor, goalName, project) + ?: return null + + val target = NxTarget("nx:run-commands", options, analysis.isCacheable, analysis.isThreadSafe) + + // Add inputs and outputs if cacheable + if (analysis.isCacheable) { + // Convert inputs to JsonNode array + val inputsArray = objectMapper.createArrayNode() + analysis.inputs.forEach { input -> inputsArray.add(input) } + analysis.dependentTaskOutputInputs.forEach { input -> + val obj = objectMapper.createObjectNode() + obj.put("dependentTasksOutputFiles", input.path) + if (input.transitive) obj.put("transitive", true) + inputsArray.add(obj) + } + target.inputs = inputsArray + + // Convert outputs to JsonNode array + val outputsArray = objectMapper.createArrayNode() + analysis.outputs.forEach { output -> outputsArray.add(output) } + target.outputs = outputsArray + } + + addBuildStateJsonInputsAndOutputs(project, target) + return target + } + + private fun getExecutablePlugins(project: MavenProject): List { + return project.build.plugins + } + + private fun generateAtomizedTestTargets( + project: MavenProject, + mavenCommand: String, + testCiTarget: NxTarget, + testGoals: MutableList, + testDependsOn: MutableList + ): Map { + val goalDescriptor = testGoals.first() + val targets = mutableMapOf() + + val testClasses = testClassDiscovery.discoverTestClasses(project) + val testCiTargetGroup = mutableListOf() + + val analysis = mojoAnalyzer.analyzeMojo(goalDescriptor.pluginDescriptor, goalDescriptor.goal, project) + ?: return emptyMap() + + testClasses.forEach { testClass -> + val targetName = "${goalDescriptor.goalSpecifier}--${testClass.packagePath}.${testClass.className}" + + log.info("Generating target for test class: $targetName'") + + val options = objectMapper.createObjectNode() + options.put( + "command", + "$mavenCommand $APPLY_GOAL ${goalDescriptor.goalSpecifier} $RECORD_GOAL -pl ${project.groupId}:${project.artifactId} -Dtest=${testClass.packagePath}.${testClass.className} -Dsurefire.failIfNoSpecifiedTests=false" + ) + + val dependsOn = objectMapper.createArrayNode() + testDependsOn.forEach { dependsOn.add(it) } + + val target = NxTarget( + "nx:run-commands", + options, + analysis.isCacheable, + analysis.isThreadSafe, + dependsOn, + objectMapper.createArrayNode(), + objectMapper.createArrayNode() + ) + + analysis.inputs.forEach { input -> target.inputs?.add(input) } + analysis.outputs.forEach { output -> target.outputs?.add(output) } + analysis.dependentTaskOutputInputs.forEach { input -> + val obj = objectMapper.createObjectNode() + obj.put("dependentTasksOutputFiles", input.path) + if (input.transitive) obj.put("transitive", true) + target.inputs?.add(obj) + } + + targets[targetName] = target + testCiTargetGroup.add(targetName) + + testCiTarget.dependsOn!!.add(targetName) + addBuildStateJsonInputsAndOutputs(project, target) + } + + return targets + } + + private fun addBuildStateJsonInputsAndOutputs(project: MavenProject, target: NxTarget) { + val buildJsonFile = File("${project.build.directory}/nx-build-state.json") + + val isIgnored = gitIgnoreClassifier.isIgnored(buildJsonFile) + if (isIgnored) { + log.warn("Input path is gitignored: ${buildJsonFile.path}") + val input = pathFormatter.toDependentTaskOutputs(buildJsonFile, project.basedir) + val obj = objectMapper.createObjectNode() + obj.put("dependentTasksOutputFiles", input.path) + if (input.transitive) obj.put("transitive", true) + target.inputs?.add(obj) + } else { + val input = pathFormatter.formatInputPath(buildJsonFile, projectRoot = project.basedir) + + target.inputs?.add(input) + } + target.outputs?.add(pathFormatter.formatOutputPath(buildJsonFile, project.basedir)) + } + + + private fun getPluginDescriptor( + plugin: Plugin, + project: MavenProject + ): PluginDescriptor = pluginManager.getPluginDescriptor( + plugin, project.remotePluginRepositories, session.repositorySession + ) + + /** + * Normalizes Maven 3 phase names to Maven 4 equivalents when running Maven 4. + * Returns the original phase name when running Maven 3. + */ + private fun normalizePhase(phase: String?): String? { + if (phase == null) return null + + val mavenVersion = session.systemProperties.getProperty("maven.version") ?: "" + if (!mavenVersion.startsWith("4")) { + return phase // Keep original phase names for Maven 3 + } + + return when (phase) { + "generate-sources" -> "sources" + "process-sources" -> "after:sources" + "generate-resources" -> "resources" + "process-resources" -> "after:resources" + "process-classes" -> "after:compile" + "generate-test-sources" -> "test-sources" + "process-test-sources" -> "after:test-sources" + "generate-test-resources" -> "test-resources" + "process-test-resources" -> "after:test-resources" + "process-test-classes" -> "after:test-compile" + "prepare-package" -> "before:package" + "pre-integration-test" -> "before:integration-test" + "post-integration-test" -> "after:integration-test" + else -> phase + } + } +} + + +/** + * Determines the execution priority for a goal within a phase. + * Maven uses priority to determine the order of execution when multiple goals are bound to the same phase. + * Lower numbers have higher priority (execute first). + */ +private fun getExecutionPriority(execution: PluginExecution): Int { + // Maven assigns priorities based on several factors: + // 1. Explicit priority in execution configuration + // 2. Plugin goal priority from mojo descriptor + // 3. Default priority (50) + // 4. Alphabetical order as tiebreaker + + // For now, we'll use a simple heuristic based on common plugin patterns + val executionId = execution.id ?: "default" + + // Well-known execution IDs that should run early + return when { + executionId.contains("generate") -> 10 + executionId.contains("process") -> 20 + executionId.contains("compile") -> 30 + executionId.contains("test-compile") -> 35 + executionId.contains("test") -> 40 + executionId.contains("package") -> 60 + executionId.contains("install") -> 70 + executionId.contains("deploy") -> 80 + else -> 50 // Default Maven priority + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/TestClassDiscovery.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/TestClassDiscovery.kt new file mode 100644 index 0000000000000..2c48b00042879 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/targets/TestClassDiscovery.kt @@ -0,0 +1,159 @@ +package dev.nx.maven.targets + +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.ConcurrentLinkedQueue + +data class TestClassInfo( + val className: String, + val packagePath: String, + val filePath: String, + val packageName: String +) + +/** + * Simple test class discovery utility for Maven projects + * Uses lightweight string matching instead of complex AST parsing + * TODO: This should be pretty similar to the gradle one. We should look into merging them. + */ +class TestClassDiscovery() { + private val log: Logger = LoggerFactory.getLogger(TestClassDiscovery::class.java) + + // Essential test annotations (simple string matching) + private val testAnnotations = setOf( + "@Test", + "@TestTemplate", + "@ParameterizedTest", + "@RepeatedTest", + "@TestFactory", + "@org.junit.Test", // JUnit 4 + "@org.testng.annotations.Test" // TestNG + ) + + /** + * Discover test classes in the given Maven project + */ + fun discoverTestClasses(project: MavenProject): List { + val testClasses = ConcurrentLinkedQueue() + + log.info("Getting Test Classes for project ${project.artifactId}") + + // Get test source roots + val testSourceRoots = project.testCompileSourceRoots + + testSourceRoots.parallelStream().forEach { testSourceRoot -> + val testDir = File(testSourceRoot.toString()) + if (!testDir.exists() || !testDir.isDirectory) { + return@forEach + } + + // Find all Java files recursively + val javaFiles = findJavaFiles(testDir) + + javaFiles.parallelStream().forEach { javaFile -> + val testClassInfo = getTestClass(javaFile, testDir) + if (testClassInfo != null) { + testClasses.add(testClassInfo) + } + } + } + + return testClasses.toList() + } + + /** + * Find all Java files in directory recursively + */ + private fun findJavaFiles(directory: File): List { + val javaFiles = mutableListOf() + + directory.walkTopDown() + .filter { it.isFile && it.name.endsWith(".java") } + .forEach { javaFiles.add(it) } + + return javaFiles + } + + /** + * Parse a Java test file using simple string matching + */ + private fun getTestClass(javaFile: File, testSourceRoot: File): TestClassInfo? { + log.info("Getting Test Classes from $javaFile") + + val content = javaFile.readText() + + // Quick check: does this file contain any test annotations? + val hasTestAnnotation = testAnnotations.any { content.contains(it) } + if (!hasTestAnnotation) { + return null + } + + // Extract package name (simple approach) + val packageName = extractPackageName(content) + + // Extract public class name (simple approach) + val className = extractPublicClassName(content) ?: return null + + // Double check: does this class actually have test methods? + if (!hasTestMethodsInClass(content, className)) { + return null + } + + // Create test class info + val packagePath = if (packageName.isNotEmpty()) "$packageName.$className" else className + val relativePath = testSourceRoot.toPath().relativize(javaFile.toPath()).toString().replace('\\', '/') + + return TestClassInfo( + className = className, + packagePath = packagePath, + filePath = relativePath, + packageName = packageName + ) + } + + /** + * Extract package name from Java file content using simple string matching + */ + private fun extractPackageName(content: String): String { + // Find "package com.example.something;" + val lines = content.lines() + for (line in lines) { + val trimmed = line.trim() + if (trimmed.startsWith("package ") && trimmed.endsWith(";")) { + return trimmed.substring(8, trimmed.length - 1).trim() + } + } + return "" + } + + /** + * Extract public class name from Java file content using simple string matching + */ + private fun extractPublicClassName(content: String): String? { + val lines = content.lines() + for (line in lines) { + val trimmed = line.trim() + if (trimmed.contains("public class ")) { + // Simple extraction: find "public class ClassName" + val parts = trimmed.split(" ", "\t").filter { it.isNotEmpty() } + val classIndex = parts.indexOf("class") + if (classIndex >= 0 && classIndex + 1 < parts.size) { + val className = parts[classIndex + 1] + // Remove generic types or extends clause + return className.split("<", "{", "extends", "implements")[0].trim() + } + } + } + return null + } + + /** + * Check if the class content actually contains test methods + */ + private fun hasTestMethodsInClass(content: String, @Suppress("UNUSED_PARAMETER") className: String): Boolean { + // Simple check: look for test annotations anywhere in the file + return testAnnotations.any { content.contains(it) } + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenCommandResolver.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenCommandResolver.kt new file mode 100644 index 0000000000000..31573a7308c40 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenCommandResolver.kt @@ -0,0 +1,75 @@ +package dev.nx.maven.utils + +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Singleton service that caches Maven command detection to avoid expensive process execution + * and file system checks on every project analysis. + */ +object MavenCommandResolver { + private val log = LoggerFactory.getLogger(MavenCommandResolver::class.java) + + private const val MVND_COMMAND = "mvnd" + private const val MVNW_COMMAND = "./mvnw" + private const val MVN_COMMAND = "mvn" + private const val MVNW_FILENAME = "mvnw" + + @Volatile + private var cachedCommand: String? = null + + /** + * Gets the best Maven executable with caching: mvnd > mvnw > mvn + */ + fun getMavenCommand(workspaceRoot: File): String { + cachedCommand?.let { + log.debug("Using cached Maven command: $it") + return it + } + + log.info("Detecting Maven command for workspace: $workspaceRoot") + val startTime = System.currentTimeMillis() + + cachedCommand = detectMavenCommand(workspaceRoot) + + val detectionTime = System.currentTimeMillis() - startTime + log.info("Maven command detection completed: '$cachedCommand' in ${detectionTime}ms") + + return cachedCommand!! + } + + private fun detectMavenCommand(workspaceRoot: File): String { + // First priority: Check for Maven Daemon + if (isMvndAvailable()) { + log.info("Found mvnd (Maven Daemon)") + return MVND_COMMAND + } + + // Second priority: Check for Maven wrapper + val mvnwFile = File(workspaceRoot, MVNW_FILENAME) + if (mvnwFile.exists() && mvnwFile.canExecute()) { + log.info("Found Maven wrapper: $MVNW_COMMAND") + return MVNW_COMMAND + } + + log.info("Falling back to system Maven: $MVN_COMMAND") + return MVN_COMMAND + } + + private fun isMvndAvailable(): Boolean { + return try { + val startTime = System.currentTimeMillis() + val process = ProcessBuilder(MVND_COMMAND, "--version") + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + val exitCode = process.waitFor() + val detectionTime = System.currentTimeMillis() - startTime + log.debug("$MVND_COMMAND detection took ${detectionTime}ms") + exitCode == 0 + } catch (e: Exception) { + log.debug("$MVND_COMMAND not available: ${e.message}") + false + } + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenExpressionResolver.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenExpressionResolver.kt new file mode 100644 index 0000000000000..191c0b28ba94e --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MavenExpressionResolver.kt @@ -0,0 +1,240 @@ +package dev.nx.maven.utils + +import org.apache.maven.execution.MavenSession +import org.apache.maven.plugin.descriptor.Parameter +import org.apache.maven.project.MavenProject +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Resolves Maven expressions and parameter values + */ +class MavenExpressionResolver( + private val session: MavenSession +) { + private val log: Logger = LoggerFactory.getLogger(MavenExpressionResolver::class.java) + + fun resolveParameter(parameter: Parameter, project: MavenProject): List { + // For compileSourceRoots and testCompileSourceRoots, always use collection handling + // to include ALL source roots (including generated sources) + if (parameter.name in setOf("compileSourceRoots", "testCompileSourceRoots")) { + return when (parameter.name) { + "compileSourceRoots" -> project.compileSourceRoots ?: emptyList() + "testCompileSourceRoots" -> project.testCompileSourceRoots ?: emptyList() + else -> emptyList() + } + } + + return when (parameter.type) { + "java.io.File" -> listOfNotNull( + resolveStringParameterValue( + parameter, + project + ) + ) + "java.lang.String" -> listOfNotNull( + resolveStringParameterValue( + parameter, + project + ) + ) + "java.util.List" -> resolveCollectionParameter(parameter, project) + "java.util.Set" -> resolveCollectionParameter(parameter, project) + else -> emptyList() + } + } + + /** + * Resolves a mojo parameter value by trying expression, default value, and known mappings + */ + fun resolveCollectionParameter( + parameter: Parameter, + project: MavenProject + ): List { + return buildList { + parameter.expression?.let { expr -> + addAll(resolveCollectionExpression(expr, project)) + } + parameter.defaultValue?.let { default -> + addAll(resolveCollectionExpression(default, project)) + } + } + } + + + /** + * Resolves a mojo parameter value by trying expression, default value, and known mappings + */ + fun resolveStringParameterValue(parameter: Parameter, project: MavenProject): String? { + // Try expression first + parameter.expression?.let { expr -> + val resolved = resolveExpression(expr, project) + if (resolved != expr) { + // Filter out values that look like version numbers, not paths + if (isValidPath(resolved)) { + return resolved + } else { + return null + } + } + } + + // Try default value + parameter.defaultValue?.let { default -> + val resolved = resolveExpression(default, project) + if (isValidPath(resolved)) { + return resolved + } else { + return null + } + } + + // Try known parameter mappings based on what Maven actually provides + val result = when (parameter.name) { + // These map directly to Maven project model + "sourceDirectory" -> project.build.sourceDirectory + "testSourceDirectory" -> project.build.testSourceDirectory + "outputDirectory" -> project.build.outputDirectory + "testOutputDirectory" -> project.build.testOutputDirectory + "buildDirectory" -> project.build.directory + "classesDirectory" -> project.build.outputDirectory + "testClassesDirectory" -> project.build.testOutputDirectory + "basedir" -> project.basedir?.absolutePath + + // Resources from project model + "resources" -> project.build.resources?.firstOrNull()?.directory + "testResources" -> project.build.testResources?.firstOrNull()?.directory + + // Classpath elements + "classpathElements", "compileClasspathElements" -> { + // Return classpath as colon-separated string of JAR paths + project.compileClasspathElements?.joinToString(System.getProperty("path.separator")) + } + + "testClasspathElements" -> { + project.testClasspathElements?.joinToString(System.getProperty("path.separator")) + } + + // Common plugin-specific paths (these are typically configured in pom.xml) + "reportsDirectory" -> "${project.build.directory}/surefire-reports" + "warSourceDirectory" -> "${project.basedir}/src/main/webapp" + + else -> null + } + + return result + } + + /** + * Checks if a resolved value looks like a valid file path rather than a version number or other non-path value + */ + private fun isValidPath(value: String?): Boolean { + if (value.isNullOrBlank()) return false + + // Filter out values that look like version numbers (e.g., "1.8", "11", "17") + // Use simple string matching instead of regex to avoid StackOverflowError + if (isVersionNumber(value)) { + return false + } + + // Filter out other common non-path values + if (value in setOf("true", "false", "UTF-8", "jar", "war", "ear", "pom", "test-jar")) { + return false + } + + // Must contain at least one path separator or be an absolute path + return value.contains("/") || value.contains("\\") || value.startsWith(".") || java.io.File(value).isAbsolute + } + + fun resolveCollectionExpression(expression: String, project: MavenProject): List { + return when(expression) { + "\${project.compileSourceRoots}" -> project.compileSourceRoots ?: emptyList() + "\${project.testCompileSourceRoots}" -> project.testCompileSourceRoots ?: emptyList() + "\${project.resources}" -> project.build.resources?.mapNotNull { it.directory } ?: emptyList() + "\${project.testResources}" -> project.build.testResources?.mapNotNull { it.directory } ?: emptyList() + "\${project.compileClasspathElements}" -> project.compileClasspathElements ?: emptyList() + "\${project.testClasspathElements}" -> project.testClasspathElements ?: emptyList() + else -> emptyList() + } + } + + /** + * Resolves Maven expressions in a string + */ + fun resolveExpression(expression: String, project: MavenProject): String { + if (!expression.contains("\${")) { + return expression + } + + var resolved = expression + + // Replace common project expressions + resolved = resolved.replace("\${project.basedir}", project.basedir?.absolutePath ?: "") + resolved = resolved.replace("\${basedir}", project.basedir?.absolutePath ?: "") + resolved = resolved.replace("\${project.build.directory}", project.build?.directory ?: "target") + resolved = + resolved.replace("\${project.build.outputDirectory}", project.build?.outputDirectory ?: "target/classes") + resolved = resolved.replace( + "\${project.build.testOutputDirectory}", + project.build?.testOutputDirectory ?: "target/test-classes" + ) + resolved = resolved.replace( + "\${project.build.finalName}", + project.build?.finalName ?: "${project.artifactId}-${project.version}" + ) + resolved = resolved.replace("\${project.artifactId}", project.artifactId ?: "") + resolved = resolved.replace("\${project.groupId}", project.groupId ?: "") + resolved = resolved.replace("\${project.version}", project.version ?: "") + resolved = resolved.replace("\${project.name}", project.name ?: "") + + // Replace session expressions + resolved = resolved.replace("\${session.executionRootDirectory}", session.executionRootDirectory ?: "") + + // Replace system properties + System.getProperties().forEach { key, value -> + resolved = resolved.replace("\${$key}", value.toString()) + } + + // Replace user properties from session + session.userProperties?.forEach { key, value -> + resolved = resolved.replace("\${$key}", value.toString()) + } + + // Replace system properties from session + session.systemProperties?.forEach { key, value -> + resolved = resolved.replace("\${$key}", value.toString()) + } + + return resolved + } + + /** + * Check if a string looks like a version number without using regex + */ + private fun isVersionNumber(value: String): Boolean { + if (value.isEmpty()) return false + + // Simple check: starts with digit and contains only digits and dots + if (!value[0].isDigit()) return false + + for (char in value) { + if (!char.isDigit() && char != '.') { + return false + } + } + + // Avoid multiple consecutive dots or ending with dot + if (value.contains("..") || value.endsWith(".")) { + return false + } + + return true + } + + fun resolveProperty(propertyPath: String, project: MavenProject): List { + return when(propertyPath) { + "project.build.resources" -> project.build.resources.mapNotNull { resource -> resource.directory } + else -> emptyList() + } + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MojoAnalyzer.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MojoAnalyzer.kt new file mode 100644 index 0000000000000..396bb096db745 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/MojoAnalyzer.kt @@ -0,0 +1,180 @@ +package dev.nx.maven.utils + +import dev.nx.maven.GitIgnoreClassifier +import dev.nx.maven.cache.CacheConfig +import org.apache.maven.plugin.descriptor.MojoDescriptor +import org.apache.maven.plugin.descriptor.PluginDescriptor +import org.apache.maven.project.MavenProject +import org.slf4j.LoggerFactory +import java.io.File + +data class MojoAnalysis( + val inputs: Set, + val dependentTaskOutputInputs: Set, + val outputs: Set, + val isCacheable: Boolean, + val isThreadSafe: Boolean, +) + +class MojoAnalyzer( + private val expressionResolver: MavenExpressionResolver, + private val pathResolver: PathFormatter, + private val gitIgnoreClassifier: GitIgnoreClassifier, +) { + private val log = LoggerFactory.getLogger(MojoAnalyzer::class.java) + + private val cacheConfig: CacheConfig by lazy { + CacheConfig.DEFAULT + } + + /** + * Aggregates cacheability, thread-safety, and input/output metadata for a mojo. + */ + fun analyzeMojo( + pluginDescriptor: PluginDescriptor, + goal: String, + project: MavenProject + ): MojoAnalysis? { + val mojoDescriptor = pluginDescriptor.getMojo(goal) + ?: run { + log.warn( + "Skipping analysis for ${pluginDescriptor.artifactId}:$goal – mojo descriptor not found" + ) + return null + } + + val isThreadSafe = mojoDescriptor.isThreadSafe + + val isCacheable = isPluginCacheable(pluginDescriptor, mojoDescriptor) + + if (!isCacheable) { + log.info("${pluginDescriptor.artifactId}:$goal is not cacheable") + return MojoAnalysis(emptySet(), emptySet(), emptySet(), false, isThreadSafe) + } + + val (inputs, dependentTaskOutputInputs) = getInputs(pluginDescriptor, mojoDescriptor, project) + val outputs = getOutputs(pluginDescriptor, mojoDescriptor, project) + + return MojoAnalysis( + inputs, + dependentTaskOutputInputs, + outputs, + true, + isThreadSafe + ) + } + + private fun getInputs( + pluginDescriptor: PluginDescriptor, + mojoDescriptor: MojoDescriptor, + project: MavenProject + ): Pair, Set> { + val mojoConfig = + cacheConfig.configurations["${pluginDescriptor.artifactId}:${mojoDescriptor.goal}"] + + val inputs = mutableSetOf() + val dependentTaskOutputInputs = mutableSetOf() + + mojoConfig?.inputParameters?.forEach { paramConfig -> + val parameter = mojoDescriptor.parameterMap[paramConfig.name] + ?: return@forEach + + val paths = expressionResolver.resolveParameter(parameter, project) + + paths.forEach { path -> + val pathWithGlob = paramConfig.glob?.let { "$path/$it" } ?: path + val pathFile = File(pathWithGlob); + val isIgnored = gitIgnoreClassifier.isIgnored(pathFile) + if (isIgnored) { + log.warn("Input path is gitignored: ${pathFile.path}") + val input = pathResolver.toDependentTaskOutputs(pathFile, project.basedir) + dependentTaskOutputInputs.add(input) + } else { + val input = pathResolver.formatInputPath(pathFile, projectRoot = project.basedir) + + inputs.add(input) + } + } + } + + mojoConfig?.inputProperties?.forEach { propertyPath -> + val paths = expressionResolver.resolveProperty(propertyPath, project) + + paths.forEach { path -> + val pathFile = File(path) + val isIgnored = gitIgnoreClassifier.isIgnored(pathFile) + if (isIgnored) { + log.warn("Input path is gitignored: ${pathFile.path}") + val input = pathResolver.toDependentTaskOutputs(pathFile, project.basedir) + dependentTaskOutputInputs.add(input) + } else { + val input = pathResolver.formatInputPath(pathFile, projectRoot = project.basedir) + + inputs.add(input) + } + } + } + + if (mojoConfig?.inputParameters == null && mojoConfig?.inputProperties == null) { + cacheConfig.defaultInputs.forEach { input -> + val pathFile = File(input.path); + val isIgnored = gitIgnoreClassifier.isIgnored(pathFile) + if (isIgnored) { + log.warn("Input path is gitignored: ${pathFile.path}") + val input = pathResolver.toDependentTaskOutputs(pathFile, project.basedir) + dependentTaskOutputInputs.add(input) + } else { + val input = pathResolver.formatInputPath(pathFile, projectRoot = project.basedir) + + inputs.add(input) + } + } + } + + return Pair(inputs, dependentTaskOutputInputs) + } + + private fun getOutputs( + pluginDescriptor: PluginDescriptor, + mojoDescriptor: MojoDescriptor, + project: MavenProject + ): Set { + val mojoConfig = cacheConfig.configurations["${pluginDescriptor.artifactId}:${mojoDescriptor.goal}"] + + val outputs = mutableSetOf() + mojoConfig?.outputParameters?.forEach { paramConfig -> + val parameter = mojoDescriptor.parameterMap[paramConfig.name] + ?: return@forEach + + val paths = expressionResolver.resolveParameter( + parameter, + project + ) + + paths.forEach { path -> + val pathWithGlob = paramConfig.glob?.let { "${path}/$it" } ?: path + val pathFile = File(pathWithGlob); + + val formattedPath = pathResolver.formatOutputPath(pathFile, project.basedir) + + outputs.add(formattedPath) + } + } + + if (mojoConfig?.outputParameters == null) { + return cacheConfig.defaultOutputs.map { output -> + val pathFile = File(output.path); + pathResolver.formatOutputPath( + pathFile, + project.basedir + ) + }.toSet() + } + + return outputs + } + + private fun isPluginCacheable(pluginDescriptor: PluginDescriptor, mojoDescriptor: MojoDescriptor): Boolean { + return !cacheConfig.nonCacheable.contains("${pluginDescriptor.artifactId}:${mojoDescriptor.goal}") + } +} diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/PathFormatter.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/PathFormatter.kt new file mode 100644 index 0000000000000..56c01c0cb7c87 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/utils/PathFormatter.kt @@ -0,0 +1,30 @@ +package dev.nx.maven.utils + +import java.io.File + +/** + * Handles path resolution, Maven command detection, and input/output path formatting for Nx + */ +class PathFormatter { + + fun formatInputPath(path: File, projectRoot: File): String { + return toProjectPath(path, projectRoot) + } + + fun toDependentTaskOutputs(path: File, projectRoot: File): DependentTaskOutputs { + val relativePath = path.relativeTo(projectRoot) + return DependentTaskOutputs(relativePath.path) + } + + fun formatOutputPath(path: File, projectRoot: File): String { + return toProjectPath(path, projectRoot) + } + + fun toProjectPath(path: File, projectRoot: File): String { + val relativePath = path.relativeToOrSelf(projectRoot) + + return "{projectRoot}/$relativePath" + } +} + +data class DependentTaskOutputs(val path: String, val transitive: Boolean = true) diff --git a/packages/maven/maven-plugin/src/main/resources/simplelogger.properties b/packages/maven/maven-plugin/src/main/resources/simplelogger.properties new file mode 100644 index 0000000000000..c9c50e1adf3b5 --- /dev/null +++ b/packages/maven/maven-plugin/src/main/resources/simplelogger.properties @@ -0,0 +1,8 @@ +# SLF4J Simple Logger configuration for Nx Maven Analyzer Plugin - Minimal format +org.slf4j.simpleLogger.showLogName=false +org.slf4j.simpleLogger.showDateTime=false +org.slf4j.simpleLogger.levelInBrackets=false +org.slf4j.simpleLogger.showThreadName=false + +# Set log levels for specific classes if needed +org.slf4j.simpleLogger.log.dev.nx.maven=INFO \ No newline at end of file diff --git a/packages/maven/package.json b/packages/maven/package.json new file mode 100644 index 0000000000000..1251bd36a8bc6 --- /dev/null +++ b/packages/maven/package.json @@ -0,0 +1,63 @@ +{ + "name": "@nx/maven", + "version": "0.0.1", + "private": false, + "description": "Nx plugin for Maven integration", + "repository": { + "type": "git", + "url": "https://github.com/nrwl/nx.git", + "directory": "packages/maven" + }, + "bugs": { + "url": "https://github.com/nrwl/nx/issues" + }, + "homepage": "https://nx.dev", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json", + "./generators.json": "./generators.json", + "./executors.json": "./executors.json", + "./migrations.json": "./migrations.json" + }, + "generators": "./generators.json", + "executors": "./executors.json", + "nx-migrations": { + "migrations": "./migrations.json" + }, + "files": [ + "dist", + "generators.json", + "executors.json", + "!README.md__tpl__" + ], + "keywords": [ + "nx", + "maven", + "plugin", + "java", + "build" + ], + "author": "Victor Savkin", + "license": "MIT", + "dependencies": { + "@nx/devkit": "workspace:*", + "@xmldom/xmldom": "^0.8.10" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@types/node": "^20.19.10", + "@types/xmldom": "^0.1.34", + "jest": "^29.4.0", + "memfs": "^4.9.2", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/maven/project.json b/packages/maven/project.json new file mode 100644 index 0000000000000..323fe79529b1c --- /dev/null +++ b/packages/maven/project.json @@ -0,0 +1,58 @@ +{ + "name": "maven", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/maven/src", + "projectType": "library", + "targets": { + "nx-release-publish": { + "dependsOn": ["^nx-release-publish", "nx-maven-plugin:_install"], + "executor": "@nx/js:release-publish", + "options": { + "packageRoot": "packages/maven" + } + }, + "build": { + "command": "node ./scripts/copy-readme.js maven packages/maven/README.md__tpl__ packages/maven/README.md", + "outputs": ["{projectRoot}/README.md"] + }, + "legacy-post-build": { + "executor": "@nx/workspace-plugin:legacy-post-build", + "outputs": ["{options.outputPath}"], + "options": { + "tsConfig": "./tsconfig.lib.json", + "outputPath": "packages/maven/dist", + "assets": [ + { + "input": "packages/maven", + "glob": "**/@(files|files-angular)/**", + "output": "/" + }, + { + "input": "packages/maven", + "glob": "**/files/**/.gitkeep", + "output": "/" + }, + { + "input": "packages/maven/src", + "glob": "**/*.json", + "ignore": ["maven-plugin/**/*"], + "output": "/" + }, + { + "input": "packages/maven", + "glob": "@(generators|executors).json", + "output": "/" + } + ] + } + } + }, + "release": { + "version": { + "preserveLocalDependencyProtocols": false, + "manifestRootsToUpdate": ["packages/{projectName}"] + } + }, + "tags": [], + "implicitDependencies": ["nx-maven-plugin"] +} diff --git a/packages/maven/src/generators/init/generator.spec.ts b/packages/maven/src/generators/init/generator.spec.ts new file mode 100644 index 0000000000000..e0504004206c3 --- /dev/null +++ b/packages/maven/src/generators/init/generator.spec.ts @@ -0,0 +1,241 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { mavenInitGenerator } from './generator'; +import { mavenPluginVersion } from '../../utils/versions'; + +describe('Maven Init Generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({}); + }); + + describe('addNxMavenAnalyzerPlugin', () => { + it('should add plugin to pom.xml without build element', async () => { + // Arrange + const pomContent = ` + + 4.0.0 + com.example + test-project + 1.0.0 +`; + + tree.write('pom.xml', pomContent); + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act + await mavenInitGenerator(tree, {}); + + // Assert + const updatedPom = tree.read('pom.xml', 'utf-8')!; + expect(updatedPom).toContain(''); + expect(updatedPom).toContain(''); + expect(updatedPom).toContain('dev.nx.maven'); + expect(updatedPom).toContain('nx-maven-plugin'); + expect(updatedPom).toContain(mavenPluginVersion); + }); + + it('should add plugin to pom.xml with build but without plugins', async () => { + // Arrange + const pomContent = ` + + 4.0.0 + com.example + test-project + 1.0.0 + + + test-project + +`; + + tree.write('pom.xml', pomContent); + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act + await mavenInitGenerator(tree, {}); + + // Assert + const updatedPom = tree.read('pom.xml', 'utf-8')!; + expect(updatedPom).toContain(''); + expect(updatedPom).toContain('test-project'); + expect(updatedPom).toContain(''); + expect(updatedPom).toContain('dev.nx.maven'); + expect(updatedPom).toContain('nx-maven-plugin'); + expect(updatedPom).toContain(mavenPluginVersion); + }); + + it('should add plugin to pom.xml with existing plugins collection', async () => { + // Arrange + const pomContent = ` + + 4.0.0 + com.example + test-project + 1.0.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + +`; + + tree.write('pom.xml', pomContent); + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act + await mavenInitGenerator(tree, {}); + + // Assert + const updatedPom = tree.read('pom.xml', 'utf-8')!; + expect(updatedPom).toContain('maven-compiler-plugin'); + expect(updatedPom).toContain('dev.nx.maven'); + expect(updatedPom).toContain('nx-maven-plugin'); + expect(updatedPom).toContain(mavenPluginVersion); + }); + + it('should not add plugin if already present', async () => { + // Arrange + const pomContent = ` + + 4.0.0 + com.example + test-project + 1.0.0 + + + + + dev.nx.maven + nx-maven-plugin + 0.0.1 + + + +`; + + tree.write('pom.xml', pomContent); + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act + await mavenInitGenerator(tree, {}); + + // Assert + const updatedPom = tree.read('pom.xml', 'utf-8')!; + const pluginOccurrences = (updatedPom.match(/nx-maven-plugin/g) || []) + .length; + expect(pluginOccurrences).toBe(1); + }); + + it('should handle missing pom.xml gracefully', async () => { + // Arrange + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act & Assert + expect(async () => { + await mavenInitGenerator(tree, {}); + }).not.toThrow(); + }); + + it('should handle empty pom.xml content gracefully', async () => { + // Arrange + tree.write('pom.xml', ''); + tree.write('package.json', JSON.stringify({ name: 'test' })); + + // Act & Assert + expect(async () => { + await mavenInitGenerator(tree, {}); + }).not.toThrow(); + }); + + it('should handle malformed XML gracefully', async () => { + // Arrange + const pomContent = ` + + 4.0.0 + com.example + + true + true + true + + + 17 + 17 + UTF-8 + UTF-8 + + + 1.9.22 + 3.9.11 + 3.11.0 + 2.16.1 + 5.10.1 + 3.24.2 + 5.7.0 + 2.0.12 + + + 3.11.0 + 3.2.5 + 3.2.5 + 3.11.0 + 3.3.0 + 3.6.3 + 3.1.0 + 1.9.10 + + + + + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-model + ${maven.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.tools.version} + provided + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-test + ${kotlin.version} + test + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + + org.junit.jupiter + junit-jupiter + ${junit5.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + 17 + + + + compile + + compile + + compile + + + test-compile + + test-compile + + test-compile + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${maven.plugin.plugin.version} + + 3.6.0 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + false + + **/*Test.java + **/*Test.kt + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven.failsafe.plugin.version} + + + + + org.apache.maven.plugins + maven-source-plugin + ${maven.source.plugin.version} + + + attach-sources + + jar-no-fork + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.plugin.version} + + + attach-javadocs + + jar + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.5.0 + + true + oss + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + + + + + + + + release + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven.gpg.plugin.version} + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + + + + + + + + diff --git a/scripts/build-maven-analyzer.js b/scripts/build-maven-analyzer.js new file mode 100644 index 0000000000000..f4634bd96583a --- /dev/null +++ b/scripts/build-maven-analyzer.js @@ -0,0 +1,7 @@ +// @ts-check + +const { execSync } = require('node:child_process'); + +if (process.env.SKIP_ANALYZER_BUILD !== 'true') { + execSync('nx install nx-maven-plugin', { stdio: 'inherit' }); +} diff --git a/scripts/commitizen.js b/scripts/commitizen.js index bcfca54b6c571..0c5cf6976f3f0 100644 --- a/scripts/commitizen.js +++ b/scripts/commitizen.js @@ -33,6 +33,7 @@ const scopes = [ { value: 'web', name: 'web: anything Web specific' }, { value: 'webpack', name: 'webpack: anything Webpack specific' }, { value: 'gradle', name: 'gradle: anything Gradle specific'}, + { value: 'maven', name: 'maven: anything Maven specific'}, { value: 'module-federation', name: 'module-federation: anything Module Federation specific'}, { value: 'docker', name: 'docker: anything Docker specific'}, { value: 'dotnet', name: 'dotnet: anything .NET specific'}, diff --git a/scripts/nx-release.ts b/scripts/nx-release.ts old mode 100755 new mode 100644 index faff34dc444dc..853bbd0319d1f --- a/scripts/nx-release.ts +++ b/scripts/nx-release.ts @@ -126,6 +126,7 @@ const VALID_AUTHORS_FOR_LATEST = [ 'packages/angular-rspack', 'packages/angular-rspack-compiler', 'packages/dotnet', + 'packages/maven', ]; const packageSnapshots: { [key: string]: string } = {}; diff --git a/tools/workspace-plugin/src/executors/legacy-post-build/executor.ts b/tools/workspace-plugin/src/executors/legacy-post-build/executor.ts index d2d2a2aa50d0c..db16836d26e10 100644 --- a/tools/workspace-plugin/src/executors/legacy-post-build/executor.ts +++ b/tools/workspace-plugin/src/executors/legacy-post-build/executor.ts @@ -170,7 +170,13 @@ Or with TypeScript config: // Output path is relative to project root, so resolve it if (!path.isAbsolute(outputPath)) { - outputPath = path.resolve(workspaceRoot, projectRoot, outputPath); + outputPath = path.resolve( + workspaceRoot, + projectRoot, + outputPath.startsWith(projectRoot) + ? path.relative(projectRoot, outputPath) + : outputPath + ); } // Ensure output directory exists diff --git a/tsconfig.json b/tsconfig.json index 74c2594bc87c7..3969d250fbcc3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -411,6 +411,12 @@ }, { "path": "./e2e/dotnet" + }, + { + "path": "./packages/maven" + }, + { + "path": "./e2e/maven" } ], "extends": "./tsconfig.base.json"