This is an automated email from the ASF dual-hosted git repository.

jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git


The following commit(s) were added to refs/heads/main by this push:
     new 56defd136b Add incremental build support to CI workflow
56defd136b is described below

commit 56defd136b5a73a64290a0687909461316601dce
Author: James Netherton <[email protected]>
AuthorDate: Wed Jun 24 14:08:46 2026 +0100

    Add incremental build support to CI workflow
    
    Integrate Maveniverse Scalpel for git-diff-based incremental builds.
    When a PR changes only a subset of modules, only the affected tests run,
    significantly reducing CI time. Full builds still trigger for changes to
    core infrastructure (build parents, extensions-core, tooling, .mvn).
    
    Key changes:
    - Add Scalpel Maven extension (.mvn/extensions.xml) for diff analysis
    - Add IncrementalBuildMojo for matrix generation and test filtering
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .github/workflows/ci-build.yaml                    | 376 +++++++++--
 .mvn/extensions.xml                                |   8 +
 .mvn/maven.config                                  |   3 +
 .../camel/quarkus/maven/IncrementalBuildMojo.java  | 701 +++++++++++++++++++++
 4 files changed, 1051 insertions(+), 37 deletions(-)

diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml
index c34db8d58a..026b5c8d8b 100644
--- a/.github/workflows/ci-build.yaml
+++ b/.github/workflows/ci-build.yaml
@@ -106,6 +106,12 @@ env:
   CQ_MAVEN_ARGS: -V -ntp -e -Daether.connector.http.connectionMaxTtl=120
   TESTCONTAINERS_RYUK_DISABLED: true
   CHECKOUT_REF: ${{ github.event_name == 'pull_request' && 
github.event.pull_request.user.login == 'dependabot[bot]' && github.head_ref || 
'' }}
+  # Scalpel configuration (defaults for all jobs; initial-mvn-install 
overrides SCALPEL_FULL_BUILD_TRIGGERS).
+  # Broad extensions-core/** trigger here is intentional: 
functional-extension-tests uses trim mode,
+  # which actively excludes modules from the build, so any extensions-core 
change should run full tests.
+  SCALPEL_FULL_BUILD_TRIGGERS: 
"-Dscalpel.fullBuildTriggers=poms/build-parent/**,poms/build-parent-it/**,extensions-core/**,tooling/maven-plugin/**,.mvn/**"
+  SCALPEL_EXCLUDE_PATHS: 
"-Dscalpel.excludePaths=**.adoc,**.md,docs/**,Jenkinsfile*,LICENSE.txt,NOTICE.txt,KEYS,camel-quarkus-sbom/**,.github/*.sh"
+  CQ_MAVEN_SKIP_CHECKS: "-Dformatter.skip -Dimpsort.skip -Denforcer.skip 
-Dcamel-quarkus.update-extension-doc-page.skip"
 
 jobs:
   pre-build-checks:
@@ -179,11 +185,25 @@ jobs:
     runs-on: ubuntu-latest
     needs: pre-build-checks
     outputs:
-      matrix: ${{ steps.set-native-matrix.outputs.matrix }}
-      examples-matrix: ${{ steps.set-examples-matrix.outputs.examples-matrix }}
+      # Miscellaneous job outputs. Keep sorted alphabetically.
       cache-key: ${{ steps.maven-cache.outputs.cache-key }}
+      # Incremental build job outputs. Keep sorted alphabetically.
+      examples-matrix: ${{ steps.set-examples-matrix.outputs.examples-matrix }}
+      integration-tests-jvm-modules: ${{ 
steps.process-incremental.outputs.integration-tests-jvm-modules || '' }}
+      integration-tests-matrix: ${{ 
steps.process-incremental.outputs.use-incremental == 'true' && 
steps.process-incremental.outputs.integration-tests-matrix || 
steps.set-native-matrix-full.outputs.matrix || '{"include":[]}' }}
+      integration-tests-modules: ${{ 
steps.incremental-build.outputs.integration-tests-modules }}
+      run-catalog-tests: ${{ 
steps.process-incremental.outputs.run-catalog-tests }}
+      run-examples: ${{ steps.process-incremental.outputs.run-examples }}
+      run-extensions-core-tests: ${{ 
steps.process-incremental.outputs.run-extensions-core-tests }}
+      run-extensions-tests: ${{ 
steps.process-incremental.outputs.run-extensions-tests }}
+      run-integration-tests-jvm: ${{ 
steps.process-incremental.outputs.run-integration-tests-jvm }}
+      run-test-framework-tests: ${{ 
steps.process-incremental.outputs.run-test-framework-tests }}
+      run-tooling-tests: ${{ 
steps.process-incremental.outputs.run-tooling-tests }}
     env:
       MAVEN_OPTS: -Xmx4600m
+      SCALPEL_ENABLED: "-Dscalpel.enabled=true"
+      SCALPEL_FULL_BUILD_TRIGGERS: 
"-Dscalpel.fullBuildTriggers=poms/build-parent/**,poms/build-parent-it/**,extensions-core/core/**,tooling/maven-plugin/**,.mvn/**"
+      SCALPEL_EXCLUDE_PATHS: "-Dscalpel.excludePaths=docs/**"
     steps:
       - name: Check free space on disk
         run: |
@@ -221,12 +241,138 @@ jobs:
         run: |
           cd ../camel
           ./mvnw ${CQ_MAVEN_ARGS} clean install -Dquickly -T1C
+      - name: Detect PR Incremental Build
+        id: detect-incremental
+        if: github.event_name == 'pull_request' && 
!contains(github.event.pull_request.labels.*.name, 'ci/disable-incremental')
+        run: |
+          echo "incremental-build=true" >> $GITHUB_OUTPUT
       - name: Update extension metadata
         run: |
           ./mvnw -N cq:update-quarkus-metadata ${CQ_MAVEN_ARGS}
       - name: mvn clean install -DskipTests
         run: |
-          eval ./mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} clean install 
-DskipTests -Dquarkus.build.skip -Pformat
+          eval ./mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} -T1C clean install 
-DskipTests -Dquarkus.build.skip -Pformat
+      - name: Scalpel Analysis
+        id: scalpel-analysis
+        if: steps.detect-incremental.outputs.incremental-build == 'true'
+        continue-on-error: true
+        run: |
+          ./mvnw validate -Dquickly \
+            ${SCALPEL_ENABLED} \
+            -Dscalpel.mode=report \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
+            ${CQ_MAVEN_ARGS}
+
+          if [ -f target/scalpel-report.json ]; then
+            echo "Scalpel report generated successfully"
+            cat target/scalpel-report.json | jq '{fullBuildTriggered, 
triggerFile, affectedModulesCount: (.affectedModules | length)}'
+          else
+            echo "Scalpel report not found"
+            exit 1
+          fi
+      - name: Warn on Scalpel failure
+        if: steps.scalpel-analysis.outcome == 'failure'
+        run: echo "::warning::Scalpel analysis failed, falling back to full 
build"
+      - name: Detect Changed Container Properties
+        id: detect-container-props
+        if: steps.detect-incremental.outputs.incremental-build == 'true' && 
steps.scalpel-analysis.outcome != 'failure'
+        run: |
+          # NOTE: We do this manual checking since the container image 
properties are not linked to any other pom.xml in the project.
+          # They are queried dynamically via Java code. This logic enables 
pom.xml to be removed from Scalpel full build triggers.
+
+          CHANGED_CONTAINER_PROPS=""
+          if [ "${{ github.event_name }}" == "pull_request" ]; then
+            BASE_SHA="${{ github.event.pull_request.base.sha }}"
+          else
+            BASE_SHA="${{ github.event.before }}"
+          fi
+          if [ -n "$BASE_SHA" ] && git diff "$BASE_SHA" -- pom.xml | grep -qE 
'[+-].*\.container\.image>'; then
+            CHANGED_CONTAINER_PROPS=$(git diff "$BASE_SHA" -- pom.xml \
+              | grep -E '^\+.*\.container\.image>' \
+              | sed -E 's/.*<([^<\/]+\.container\.image)>.*/\1/' \
+              | paste -sd,)
+            echo "Changed container properties: $CHANGED_CONTAINER_PROPS"
+          fi
+          echo "props=$CHANGED_CONTAINER_PROPS" >> $GITHUB_OUTPUT
+      - name: Incremental Build Analysis
+        id: incremental-build
+        if: steps.detect-incremental.outputs.incremental-build == 'true' && 
steps.scalpel-analysis.outcome != 'failure'
+        continue-on-error: true
+        run: |
+          # Run unified incremental build analysis
+          # All directory patterns are explicitly configured here for 
visibility and maintainability
+          # Extract project version dynamically to avoid hardcoding
+          PROJECT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version 
-q -DforceStdout -N)
+          echo "Using camel-quarkus-maven-plugin version: $PROJECT_VERSION"
+
+          CONTAINER_PROPS="${{ steps.detect-container-props.outputs.props }}"
+
+          ./mvnw 
org.apache.camel.quarkus:camel-quarkus-maven-plugin:${PROJECT_VERSION}:incremental-build
 \
+            -Dcq.action=analyze \
+            -Dcq.useIncrementalBuild=true \
+            -Dcq.extensionDirs="extensions/,extensions-jvm/,extensions-core/" \
+            
-Dcq.integrationTestDirs="integration-tests/,integration-tests-jvm/" \
+            -Dcq.nativeTestsPrefix="integration-tests/" \
+            -Dcq.jvmTestsPrefix="integration-tests-jvm/" \
+            -Dcq.integrationTestGroupsPrefix="integration-test-groups/" \
+            
-Dcq.functionalScopeDirs="extensions-core/:runExtensionsCoreTests,extensions/:runExtensionsTests,test-framework/:runTestFrameworkTests,tooling/:runToolingTests,catalog/:runCatalogTests"
 \
+            
${CONTAINER_PROPS:+-Dcq.changedContainerProperties="$CONTAINER_PROPS"} \
+            -N ${CQ_MAVEN_ARGS}
+
+          if [ -f target/incremental-build.json ]; then
+            echo "Incremental build analysis complete"
+            cat target/incremental-build.json
+
+            # Output entire JSON as single output (compact for GitHub Actions)
+            echo "data=$(cat target/incremental-build.json | jq -c '.')" >> 
$GITHUB_OUTPUT
+
+            # Only set integration-tests-modules for actual incremental builds.
+            # For full builds (incrementalBuild=false), leave it empty so 
downstream jobs run all modules.
+            IS_INCREMENTAL=$(cat target/incremental-build.json | jq -r 
'.incrementalBuild // false')
+            if [ "$IS_INCREMENTAL" == "true" ]; then
+              INTEGRATION_TESTS_MODULES=$(cat target/incremental-build.json | 
jq -c '{modules: .affectedModules, modulesCsv: (.affectedModules | join(",")), 
fullBuild: false, totalModules: .affectedModulesCount}')
+              echo "integration-tests-modules=$INTEGRATION_TESTS_MODULES" >> 
$GITHUB_OUTPUT
+            fi
+          else
+            echo "Incremental build analysis failed - falling back to full 
build"
+            # Output empty JSON structure for fallback
+            echo "data={\"incrementalBuild\":false}" >> $GITHUB_OUTPUT
+          fi
+      - name: Process Incremental Build Data
+        id: process-incremental
+        run: |
+          # Parse JSON and resolve all outputs.
+          # For full builds, all run-* flags resolve to true so job outputs 
are simple pass-throughs.
+          INCREMENTAL=false
+          if [ -f target/incremental-build.json ]; then
+            DATA=$(cat target/incremental-build.json)
+            INCREMENTAL=$(echo "$DATA" | jq -r '.incrementalBuild // false')
+          fi
+
+          echo "use-incremental=$INCREMENTAL" >> $GITHUB_OUTPUT
+
+          if [ "$INCREMENTAL" == "true" ]; then
+            echo "Incremental build will be used"
+            echo "integration-tests-matrix=$(echo "$DATA" | jq -c 
'.nativeTestMatrix')" >> $GITHUB_OUTPUT
+            echo "run-integration-tests-jvm=$(echo "$DATA" | jq -r 
'.integrationTestsJvm.runTests')" >> $GITHUB_OUTPUT
+            echo "integration-tests-jvm-modules=$(echo "$DATA" | jq -r 
'.integrationTestsJvm.modules')" >> $GITHUB_OUTPUT
+            echo "run-extensions-core-tests=$(echo "$DATA" | jq -r 
'.functionalTestScope.runExtensionsCoreTests')" >> $GITHUB_OUTPUT
+            echo "run-extensions-tests=$(echo "$DATA" | jq -r 
'.functionalTestScope.runExtensionsTests')" >> $GITHUB_OUTPUT
+            echo "run-examples=$(echo "$DATA" | jq -r '.runExamples')" >> 
$GITHUB_OUTPUT
+            echo "run-test-framework-tests=$(echo "$DATA" | jq -r 
'.functionalTestScope.runTestFrameworkTests')" >> $GITHUB_OUTPUT
+            echo "run-tooling-tests=$(echo "$DATA" | jq -r 
'.functionalTestScope.runToolingTests')" >> $GITHUB_OUTPUT
+            echo "run-catalog-tests=$(echo "$DATA" | jq -r 
'.functionalTestScope.runCatalogTests')" >> $GITHUB_OUTPUT
+          else
+            echo "Full build will be used"
+            echo "run-integration-tests-jvm=true" >> $GITHUB_OUTPUT
+            echo "run-extensions-core-tests=true" >> $GITHUB_OUTPUT
+            echo "run-extensions-tests=true" >> $GITHUB_OUTPUT
+            echo "run-examples=true" >> $GITHUB_OUTPUT
+            echo "run-test-framework-tests=true" >> $GITHUB_OUTPUT
+            echo "run-tooling-tests=true" >> $GITHUB_OUTPUT
+            echo "run-catalog-tests=true" >> $GITHUB_OUTPUT
+          fi
       - name: Sync Maven properties
         run: |
           ./mvnw cq:sync-versions ${CQ_MAVEN_ARGS} -N
@@ -252,11 +398,24 @@ jobs:
         with:
           action: save
           cache-key: ${{ steps.maven-cache.outputs.cache-key }}
-      - name: Setup Native Test Matrix
-        id: set-native-matrix
+      - name: Setup Native Test Matrix (Full Build)
+        id: set-native-matrix-full
+        if: steps.process-incremental.outputs.use-incremental != 'true'
         run: |
+          echo "Using all test categories (full build)"
           CATEGORIES=$(yq -M -N -I 0 -o=json e 'keys' 
tooling/scripts/test-categories.yaml | tr '"' "'")
-          echo "matrix={'category': ${CATEGORIES}}" >> $GITHUB_OUTPUT
+          MATRIX_FULL="{'category': ${CATEGORIES}}"
+
+          # Validate matrix size (convert single quotes to double quotes for 
jq)
+          MATRIX_SIZE=$(echo "$MATRIX_FULL" | sed "s/'/\"/g" | jq '.category | 
length')
+          echo "Native tests matrix size: $MATRIX_SIZE jobs (full build)"
+          if [ "$MATRIX_SIZE" -gt 20 ]; then
+            echo "ERROR: Native tests matrix size ($MATRIX_SIZE) exceeds 
maximum expected (20)"
+            echo "Matrix content: $MATRIX_FULL"
+            exit 1
+          fi
+
+          echo "matrix=$MATRIX_FULL" >> $GITHUB_OUTPUT
       - name: Setup Examples Matrix
         id: set-examples-matrix
         run: |
@@ -272,13 +431,15 @@ jobs:
           echo "examples-matrix=${EXAMPLES_MATRIX}" >> $GITHUB_OUTPUT
 
   native-tests:
-    name: Native Tests - ${{matrix.category}}
+    name: Native Tests - ${{ matrix.name || matrix.category }}
     needs: initial-mvn-install
     runs-on: ubuntu-latest
-    if: github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM')
+    if: |
+      (github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM'))
+      && 
(fromJson(needs.initial-mvn-install.outputs.integration-tests-matrix).include[0]
 != null || 
fromJson(needs.initial-mvn-install.outputs.integration-tests-matrix).category[0]
 != null)
     strategy:
       fail-fast: false
-      matrix: ${{ fromJson(needs.initial-mvn-install.outputs.matrix) }}
+      matrix: ${{ 
fromJson(needs.initial-mvn-install.outputs.integration-tests-matrix) }}
     steps:
       - name: Checkout
         uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 
v7.0.0
@@ -299,36 +460,54 @@ jobs:
           download-maven-repo: 'true'
       - name: Integration Tests
         run: |
-          for MODULE in $(yq -M -N e ".${{ matrix.category }}" 
tooling/scripts/test-categories.yaml | grep -vE '^\s*#' | cut -f2 -d' '); do
-            if [[ "${MODULE}" == "null" ]]; then
-              continue
-            fi
+          # Check if this is incremental build (modules) or full build 
(category)
+          if [ -n "${{ matrix.modules }}" ]; then
+            echo "Incremental build - running modules: ${{ matrix.modules }}"
+            # Split comma-separated modules
+            IFS=',' read -ra MODULE_LIST <<< "${{ matrix.modules }}"
+            for MODULE_NAME in "${MODULE_LIST[@]}"; do
+              MODULE="integration-tests/${MODULE_NAME}"
 
-            MODULE="integration-tests/$(echo ${MODULE} | sed 's/^[ \t]*//;s/[ 
\t]*$//')"
+              if [[ "x$(./mvnw 
org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate 
-Dexpression=ci.native.tests.skip -DforceStdout -q -f ${MODULE})" == "xtrue" 
]]; then
+                JVM_MODULES+=("${MODULE}")
+              else
+                NATIVE_MODULES+=("${MODULE}")
+              fi
+            done
+          else
+            echo "Full build - running category: ${{ matrix.category }}"
+            # Original category-based logic
+            for MODULE in $(yq -M -N e ".${{ matrix.category }}" 
tooling/scripts/test-categories.yaml | grep -vE '^\s*#' | cut -f2 -d' '); do
+              if [[ "${MODULE}" == "null" ]]; then
+                continue
+              fi
 
-            if [[ "x$(./mvnw 
org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate 
-Dexpression=ci.native.tests.skip -DforceStdout -q -f ${MODULE})" == "xtrue" 
]]; then
-              JVM_MODULES+=("${MODULE}")
-            else
-              NATIVE_MODULES+=("${MODULE}")
-            fi
-          done
+              MODULE="integration-tests/$(echo ${MODULE} | sed 's/^[ 
\t]*//;s/[ \t]*$//')"
+
+              if [[ "x$(./mvnw 
org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate 
-Dexpression=ci.native.tests.skip -DforceStdout -q -f ${MODULE})" == "xtrue" 
]]; then
+                JVM_MODULES+=("${MODULE}")
+              else
+                NATIVE_MODULES+=("${MODULE}")
+              fi
+            done
+          fi
 
           if [[ ${#JVM_MODULES[@]} -eq 0 ]] && [[ ${#NATIVE_MODULES[@]} -eq 0 
]]; then
-            echo "No test modules were found for category ${{ matrix.category 
}}"
+            echo "No test modules were found"
             exit 1
           fi
 
           IFS=,
           if [[ ${JVM_MODULES[@]} ]]; then
             eval ./mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} clean test \
-              -Dformatter.skip -Dimpsort.skip -Denforcer.skip \
+              ${CQ_MAVEN_SKIP_CHECKS} \
               -Pdocker,ci \
               -pl "${JVM_MODULES[*]}"
           fi
 
           if [[ ${NATIVE_MODULES[@]} ]]; then
             eval ./mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} clean verify \
-              -Dformatter.skip -Dimpsort.skip -Denforcer.skip \
+              ${CQ_MAVEN_SKIP_CHECKS} \
               -Dquarkus.native.builder-image.pull=missing \
               -Pnative,docker,ci \
               --fail-at-end \
@@ -347,8 +526,16 @@ jobs:
   functional-extension-tests:
     runs-on: ubuntu-latest
     needs: initial-mvn-install
+    if: >-
+      (github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM'))
+      && (needs.initial-mvn-install.outputs.run-extensions-core-tests == 'true'
+      || needs.initial-mvn-install.outputs.run-extensions-tests == 'true'
+      || needs.initial-mvn-install.outputs.run-test-framework-tests == 'true'
+      || needs.initial-mvn-install.outputs.run-tooling-tests == 'true'
+      || needs.initial-mvn-install.outputs.run-catalog-tests == 'true')
     env:
       MAVEN_OPTS: -Xmx3000m
+      SCALPEL_TRIM_ARGS: "-Dscalpel.enabled=true -Dscalpel.mode=trim 
-Dscalpel.alsoMake=true -Dscalpel.alsoMakeDependents=false"
     steps:
       - name: Checkout
         uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 
v7.0.0
@@ -366,10 +553,14 @@ jobs:
           cache-key: ${{ needs.initial-mvn-install.outputs.cache-key }}
           download-maven-repo: 'true'
       - name: cd extensions-core && mvn test
+        if: needs.initial-mvn-install.outputs.run-extensions-core-tests == 
'true'
         run: |
           cd extensions-core
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip 
-Dcamel-quarkus.update-extension-doc-page.skip \
+            ${CQ_MAVEN_SKIP_CHECKS} \
+            ${SCALPEL_TRIM_ARGS} \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
             --fail-at-end \
             test
       - name: Report test failures
@@ -378,10 +569,14 @@ jobs:
         with:
           test-report-xml-base-dir: extensions-core
       - name: cd extensions && mvn test
+        if: needs.initial-mvn-install.outputs.run-extensions-tests == 'true'
         run: |
           cd extensions
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip 
-Dcamel-quarkus.update-extension-doc-page.skip \
+            ${CQ_MAVEN_SKIP_CHECKS} \
+            ${SCALPEL_TRIM_ARGS} \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
             --fail-at-end \
             test
       - name: Report test failures
@@ -390,10 +585,14 @@ jobs:
         with:
           test-report-xml-base-dir: extensions
       - name: cd test-framework && mvn test
+        if: needs.initial-mvn-install.outputs.run-test-framework-tests == 
'true'
         run: |
           cd test-framework
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip 
-Dcamel-quarkus.update-extension-doc-page.skip \
+            ${CQ_MAVEN_SKIP_CHECKS} \
+            ${SCALPEL_TRIM_ARGS} \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
             --fail-at-end \
             test
       - name: Report test failures
@@ -402,10 +601,14 @@ jobs:
         with:
           test-report-xml-base-dir: test-framework
       - name: cd tooling && mvn verify
+        if: needs.initial-mvn-install.outputs.run-tooling-tests == 'true'
         run: |
           cd tooling
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip \
+            ${CQ_MAVEN_SKIP_CHECKS} \
+            ${SCALPEL_TRIM_ARGS} \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
             --fail-at-end \
             verify
       - name: Report test failures
@@ -414,10 +617,14 @@ jobs:
         with:
           test-report-xml-base-dir: tooling
       - name: cd catalog && mvn test
+        if: needs.initial-mvn-install.outputs.run-catalog-tests == 'true'
         run: |
           cd catalog
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip \
+            ${CQ_MAVEN_SKIP_CHECKS} \
+            ${SCALPEL_TRIM_ARGS} \
+            ${SCALPEL_FULL_BUILD_TRIGGERS} \
+            ${SCALPEL_EXCLUDE_PATHS} \
             test
       - name: Report test failures
         uses: ./.github/actions/test-summary-report
@@ -428,6 +635,7 @@ jobs:
   integration-tests-jvm:
     runs-on: ubuntu-latest
     needs: initial-mvn-install
+    if: needs.initial-mvn-install.outputs.run-integration-tests-jvm == 'true'
     env:
       MAVEN_OPTS: -Xmx3000m
     steps:
@@ -449,8 +657,19 @@ jobs:
       - name: cd integration-tests-jvm && mvn clean test
         run: |
           cd integration-tests-jvm
+          JVM_MODULES="${{ 
needs.initial-mvn-install.outputs.integration-tests-jvm-modules }}"
+
+          if [ -n "$JVM_MODULES" ]; then
+            echo "Running incremental JVM tests for: $JVM_MODULES"
+            PL_ARGS="-pl $JVM_MODULES"
+          else
+            echo "Running all JVM tests"
+            PL_ARGS=""
+          fi
+
           ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dformatter.skip -Dimpsort.skip -Denforcer.skip \
+            $PL_ARGS \
+            ${CQ_MAVEN_SKIP_CHECKS} \
             --fail-at-end \
             clean test
       - name: Report test failures
@@ -466,7 +685,9 @@ jobs:
       fail-fast: false
       matrix:
         os: [ 'windows-latest' ]
-    if: github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM')
+    if: |
+      (github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM'))
+      && (needs.initial-mvn-install.outputs.integration-tests-modules == '' || 
fromJson(needs.initial-mvn-install.outputs.integration-tests-modules).totalModules
 > 0)
     env:
       MAVEN_OPTS: -Xmx3000m -Xms3000m
     steps:
@@ -489,10 +710,23 @@ jobs:
         shell: bash
         run: |
           cd integration-tests
-          ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
-            -Dskip-testcontainers-tests -Dformatter.skip -Dimpsort.skip 
-Denforcer.skip \
-            --fail-at-end \
-            clean verify
+          if [ -n "${{ 
needs.initial-mvn-install.outputs.integration-tests-modules }}" ]; then
+            # Incremental build - use affected modules from filtered matrix
+            AFFECTED_MODULES=$(echo '${{ 
needs.initial-mvn-install.outputs.integration-tests-modules }}' | jq -r 
'.modulesCsv')
+            echo "Running incremental build with affected modules: 
$AFFECTED_MODULES"
+            ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
+              -pl "$AFFECTED_MODULES" \
+              -Dskip-testcontainers-tests ${CQ_MAVEN_SKIP_CHECKS} \
+              --fail-at-end \
+              clean verify
+          else
+            # Full build - run all modules
+            echo "Running full build - all modules"
+            ../mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} \
+              -Dskip-testcontainers-tests ${CQ_MAVEN_SKIP_CHECKS} \
+              --fail-at-end \
+              clean verify
+          fi
       - name: Report test failures
         uses: ./.github/actions/test-summary-report
         if: ${{ failure() }}
@@ -503,7 +737,9 @@ jobs:
     name: Examples Tests - ${{matrix.name}}
     needs: initial-mvn-install
     runs-on: ubuntu-latest
-    if: github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM')
+    if: |
+      (github.event_name != 'pull_request' || 
!contains(github.event.pull_request.labels.*.name, 'JVM'))
+      && needs.initial-mvn-install.outputs.run-examples == 'true'
     strategy:
       fail-fast: false
       matrix: ${{ fromJson(needs.initial-mvn-install.outputs.examples-matrix) 
}}
@@ -540,13 +776,79 @@ jobs:
             && echo "Current Examples commit:" $(git rev-parse HEAD) \
             && ./mvnw ${CQ_MAVEN_ARGS} ${BRANCH_OPTIONS} 
org.l2x6.cq:cq-maven-plugin:2.10.0:examples-set-platform 
-Dcq.camel-quarkus.version=${CQ_VERSION}
 
+          # Filter examples for incremental builds
+          MODULES_TO_BUILD="${EXAMPLE_MODULES//,/ }"
+
+          if [ -n "${{ 
needs.initial-mvn-install.outputs.integration-tests-modules }}" ]; then
+            echo "Incremental build detected - filtering examples based on 
affected extensions"
+
+            # Get affected modules from filtered matrix
+            # Get affected modules as space-separated list for iteration
+            AFFECTED_MODULES=$(echo '${{ 
needs.initial-mvn-install.outputs.integration-tests-modules }}' | jq -r 
'.modulesCsv' | tr ',' ' ')
+            echo "Affected modules: $AFFECTED_MODULES"
+
+            # Check if extensions-core/core is affected (build all examples if 
so)
+            if echo "$AFFECTED_MODULES" | grep -q "^core$"; then
+              echo "Core extension affected - building all examples"
+            else
+              # Filter examples based on dependency analysis
+              echo "Affected modules for filtering: $AFFECTED_MODULES"
+              FILTERED_MODULES=()
+
+              for MODULE in ${EXAMPLE_MODULES//,/ }; do
+                echo "Analyzing dependencies for example: $MODULE"
+                cd ${MODULE}
+
+                # Use quarkus:dependency-list which works per-module (root 
pom.xml is just an aggregator)
+                echo "  Running: ../mvnw quarkus:dependency-list 
-DoutputFile=target/deps.txt -q"
+                ../mvnw ${CQ_MAVEN_ARGS} quarkus:dependency-list 
-DoutputFile=target/deps.txt -q 2>&1 || true
+
+                if [ -f target/deps.txt ]; then
+                  echo "  ✓ deps.txt created ($(wc -l < target/deps.txt) 
lines)"
+                  # Check if any affected extension appears in dependencies
+                  SHOULD_BUILD=false
+                  for AFFECTED in $AFFECTED_MODULES; do
+                    # Convert module name to artifact ID (e.g., "box" -> 
"camel-quarkus-box")
+                    ARTIFACT_ID="camel-quarkus-${AFFECTED}"
+                    # Use word boundary matching to avoid partial matches 
(e.g., box matching dropbox)
+                    if grep -qw "$ARTIFACT_ID" target/deps.txt; then
+                      echo "  → Depends on affected extension: $ARTIFACT_ID"
+                      SHOULD_BUILD=true
+                      break
+                    fi
+                  done
+
+                  if [ "$SHOULD_BUILD" == "true" ]; then
+                    FILTERED_MODULES+=("$MODULE")
+                  else
+                    echo "  → No affected dependencies - skipping"
+                  fi
+                else
+                  echo "  → Could not determine dependencies - including for 
safety"
+                  FILTERED_MODULES+=("$MODULE")
+                fi
+
+                cd -
+              done
+
+              if [ ${#FILTERED_MODULES[@]} -eq 0 ]; then
+                echo "No examples depend on affected extensions - skipping all"
+                exit 0
+              fi
+
+              MODULES_TO_BUILD="${FILTERED_MODULES[@]}"
+              echo "Filtered examples to build: $MODULES_TO_BUILD"
+            fi
+          fi
+
           BUILD_FAILURES=()
 
-          for MODULE in ${EXAMPLE_MODULES//,/ }; do
+          for MODULE in $MODULES_TO_BUILD; do
+            echo "Building example: $MODULE"
             cd ${MODULE}
 
             ../mvnw ${CQ_MAVEN_ARGS} clean verify \
-              -Dformatter.skip -Dimpsort.skip \
+              ${CQ_MAVEN_SKIP_CHECKS} \
               -Dquarkus.native.builder-image.pull=missing \
               -Pnative,docker,ci
 
diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml
new file mode 100644
index 0000000000..b70339124d
--- /dev/null
+++ b/.mvn/extensions.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<extensions>
+    <extension>
+        <groupId>eu.maveniverse.maven.scalpel</groupId>
+        <artifactId>extension</artifactId>
+        <version>0.3.5</version>
+    </extension>
+</extensions>
diff --git a/.mvn/maven.config b/.mvn/maven.config
index 7408098dbe..9b174c4c84 100644
--- a/.mvn/maven.config
+++ b/.mvn/maven.config
@@ -1,2 +1,5 @@
 -Daether.remoteRepositoryFilter.groupId=true
 
-Daether.remoteRepositoryFilter.groupId.basedir=${session.rootDirectory}/.mvn/rrf/
+
+# Scalpel is disabled by default, only enabled in specific CI steps
+-Dscalpel.enabled=false
diff --git 
a/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/IncrementalBuildMojo.java
 
b/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/IncrementalBuildMojo.java
new file mode 100644
index 0000000000..ae1d58a6a2
--- /dev/null
+++ 
b/tooling/maven-plugin/src/main/java/org/apache/camel/quarkus/maven/IncrementalBuildMojo.java
@@ -0,0 +1,701 @@
+/*
+ * 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.
+ */
+package org.apache.camel.quarkus.maven;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+/**
+ * Unified mojo for incremental build analysis and matrix generation.
+ * <p>
+ * Supports multiple actions via the {@code -Dcq.action} parameter:
+ * <ul>
+ * <li>{@code analyze} - Performs all analysis operations and outputs 
comprehensive JSON (recommended)</li>
+ * <li>{@code filter-modules} - Extracts affected modules from Scalpel 
report</li>
+ * <li>{@code native-matrix} - Generates native test matrix with balanced 
distribution</li>
+ * <li>{@code alternate-jvm-matrix} - Generates alternate JVM test matrix</li>
+ * <li>{@code functional-scope} - Detects which functional test scopes are 
affected</li>
+ * <li>{@code jvm-tests} - Detects affected JVM-only test modules</li>
+ * </ul>
+ * <p>
+ * Usage:
+ *
+ * <pre>
+ * mvn org.apache.camel.quarkus:camel-quarkus-maven-plugin:incremental-build \
+ *   -Dcq.action=analyze \
+ *   -Dcq.useIncrementalBuild=true \
+ *   -N
+ * </pre>
+ */
+@Mojo(name = "incremental-build", threadSafe = true, requiresProject = false)
+public class IncrementalBuildMojo extends AbstractMojo {
+
+    private static final TypeReference<Map<String, Object>> JSON_TYPE_REF = 
new TypeReference<>() {
+    };
+
+    /**
+     * Action to perform. Supported values:
+     * <ul>
+     * <li>analyze - Full analysis (all operations)</li>
+     * <li>filter-modules - Extract affected modules</li>
+     * <li>native-matrix - Generate native test matrix</li>
+     * <li>alternate-jvm-matrix - Generate alternate JVM matrix</li>
+     * <li>functional-scope - Detect functional test scope</li>
+     * <li>jvm-tests - Detect JVM-only tests</li>
+     * </ul>
+     */
+    @Parameter(property = "cq.action", required = true)
+    String action;
+
+    /**
+     * Path to Scalpel's JSON report file
+     */
+    @Parameter(defaultValue = 
"${maven.multiModuleProjectDirectory}/target/scalpel-report.json", property = 
"cq.scalpelReportJson")
+    Path scalpelReportJson;
+
+    /**
+     * Path to test-categories.yaml file (for full builds)
+     */
+    @Parameter(defaultValue = 
"${maven.multiModuleProjectDirectory}/tooling/scripts/test-categories.yaml", 
property = "cq.testCategoriesFile")
+    Path testCategoriesFile;
+
+    /**
+     * Path to write the output JSON file
+     */
+    @Parameter(defaultValue = 
"${maven.multiModuleProjectDirectory}/target/incremental-build.json", property 
= "cq.outputFile")
+    Path outputFile;
+
+    /**
+     * Whether to use incremental build (true) or full build (false)
+     */
+    @Parameter(property = "cq.useIncrementalBuild", defaultValue = "false")
+    boolean useIncrementalBuild;
+
+    /**
+     * Maximum number of groups for native test matrix distribution
+     */
+    @Parameter(property = "cq.maxGroups", defaultValue = "13")
+    int maxGroups;
+
+    /**
+     * Maximum allowed matrix size (validation)
+     */
+    @Parameter(property = "cq.maxMatrixSize", defaultValue = "20")
+    int maxMatrixSize;
+
+    /**
+     * Output compact JSON (single line)
+     */
+    @Parameter(property = "cq.outputCompact", defaultValue = "true")
+    boolean outputCompact;
+
+    /**
+     * Comma-separated list of extension directory prefixes to detect 
extension changes.
+     * Default: extensions/,extensions-jvm/,extensions-core/
+     */
+    @Parameter(property = "cq.extensionDirs", defaultValue = 
"extensions/,extensions-jvm/,extensions-core/")
+    String extensionDirs;
+
+    /**
+     * Comma-separated list of integration test directory prefixes.
+     * Default: integration-tests/,integration-tests-jvm/
+     */
+    @Parameter(property = "cq.integrationTestDirs", defaultValue = 
"integration-tests/,integration-tests-jvm/")
+    String integrationTestDirs;
+
+    /**
+     * Prefix for native-supported integration tests (used for filtering).
+     * Default: integration-tests/
+     */
+    @Parameter(property = "cq.nativeTestsPrefix", defaultValue = 
"integration-tests/")
+    String nativeTestsPrefix;
+
+    /**
+     * Prefix for JVM-only integration tests.
+     * Default: integration-tests-jvm/
+     */
+    @Parameter(property = "cq.jvmTestsPrefix", defaultValue = 
"integration-tests-jvm/")
+    String jvmTestsPrefix;
+
+    /**
+     * Prefix for grouped integration tests.
+     * Path structure: integration-test-groups/&lt;group&gt;/&lt;module&gt;/
+     * Default: integration-test-groups/
+     */
+    @Parameter(property = "cq.integrationTestGroupsPrefix", defaultValue = 
"integration-test-groups/")
+    String integrationTestGroupsPrefix;
+
+    /**
+     * Comma-separated list of directory prefixes for functional test scope 
detection.
+     * Format: prefix:scopeName
+     * Default:
+     * 
extensions-core/:runExtensionsCoreTests,extensions/:runExtensionsTests,test-framework/:runTestFrameworkTests,tooling/:runToolingTests,catalog/:runCatalogTests
+     */
+    @Parameter(property = "cq.functionalScopeDirs", defaultValue = 
"extensions-core/:runExtensionsCoreTests,extensions/:runExtensionsTests,test-framework/:runTestFrameworkTests,tooling/:runToolingTests,catalog/:runCatalogTests")
+    String functionalScopeDirs;
+
+    /**
+     * Prefix for shared integration test support modules.
+     * Default: integration-tests-support/
+     */
+    @Parameter(property = "cq.integrationTestSupportPrefix", defaultValue = 
"integration-tests-support/")
+    String integrationTestSupportPrefix;
+
+    /**
+     * Comma-separated list of changed container image property names (e.g.
+     * {@code kafka.container.image,mysql.container.image}).
+     * When set, TestResource.java files are scanned to find which test 
modules reference these
+     * properties, and those modules are added to the affected set.
+     */
+    @Parameter(property = "cq.changedContainerProperties")
+    String changedContainerProperties;
+
+    /**
+     * Project root directory used for scanning TestResource files.
+     */
+    @Parameter(defaultValue = "${maven.multiModuleProjectDirectory}", property 
= "cq.projectRootDir")
+    Path projectRootDir;
+
+    private final ObjectMapper jsonMapper = new ObjectMapper();
+
+    @Override
+    public void execute() throws MojoExecutionException, MojoFailureException {
+        try {
+            Map<String, Object> result;
+
+            switch (action) {
+            case "analyze":
+                result = performFullAnalysis();
+                break;
+            case "filter-modules":
+                result = filterModules();
+                break;
+            case "native-matrix": {
+                ScalpelReport report = readScalpelReport();
+                ContainerAffectedModules containerModules = 
detectContainerAffectedModules();
+                List<String> modules = (List<String>) filterModules(report, 
containerModules).get("modules");
+                result = generateNativeMatrix(modules);
+                break;
+            }
+            case "functional-scope":
+                result = detectFunctionalScope(readScalpelReport());
+                break;
+            case "jvm-tests":
+                result = detectJvmTests(readScalpelReport(), 
detectContainerAffectedModules());
+                break;
+            default:
+                throw new MojoExecutionException("Unknown action: " + action + 
". Supported: analyze, filter-modules, "
+                        + "native-matrix, alternate-jvm-matrix, 
functional-scope, jvm-tests");
+            }
+
+            writeOutput(result);
+            getLog().info("Incremental build analysis complete (action=" + 
action + ")");
+
+        } catch (Exception e) {
+            throw new MojoExecutionException("Failed to execute incremental 
build analysis", e);
+        }
+    }
+
+    private Map<String, Object> performFullAnalysis() throws IOException, 
MojoExecutionException {
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        ScalpelReport report = readScalpelReport();
+        ContainerAffectedModules containerModules = 
detectContainerAffectedModules();
+
+        Map<String, Object> moduleData = filterModules(report, 
containerModules);
+        result.put("incrementalBuild", moduleData.get("incrementalBuild"));
+        result.put("affectedModulesCount", moduleData.get("totalModules"));
+        result.put("affectedModules", moduleData.get("modules"));
+
+        List<String> modules = (List<String>) moduleData.get("modules");
+        result.put("nativeTestMatrix", generateNativeMatrix(modules));
+        result.put("functionalTestScope", detectFunctionalScope(report));
+        result.put("integrationTestsJvm", detectJvmTests(report, 
containerModules));
+        result.put("runExamples", shouldRunExamples(report));
+
+        return result;
+    }
+
+    private ScalpelReport readScalpelReport() throws IOException {
+        if (!useIncrementalBuild || !Files.exists(scalpelReportJson)) {
+            return null;
+        }
+        Map<String, Object> raw = 
jsonMapper.readValue(scalpelReportJson.toFile(), JSON_TYPE_REF);
+        return new ScalpelReport(
+                Boolean.TRUE.equals(raw.get("fullBuildTriggered")),
+                (List<Map<String, Object>>) raw.get("affectedModules"));
+    }
+
+    private static class ScalpelReport {
+        final boolean fullBuildTriggered;
+        final List<Map<String, Object>> affectedModules;
+
+        ScalpelReport(boolean fullBuildTriggered, List<Map<String, Object>> 
affectedModules) {
+            this.fullBuildTriggered = fullBuildTriggered;
+            this.affectedModules = affectedModules != null ? affectedModules : 
List.of();
+        }
+    }
+
+    private Map<String, Object> filterModules() throws IOException {
+        return filterModules(readScalpelReport(), 
detectContainerAffectedModules());
+    }
+
+    private Map<String, Object> filterModules(ScalpelReport report, 
ContainerAffectedModules containerModules) {
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        if (report == null) {
+            getLog().info("Full build mode (useIncrementalBuild=" + 
useIncrementalBuild + ")");
+            result.put("incrementalBuild", false);
+            result.put("modules", new ArrayList<>());
+            result.put("totalModules", 0);
+            return result;
+        }
+
+        if (report.fullBuildTriggered) {
+            getLog().info("Full build triggered by Scalpel");
+            result.put("incrementalBuild", false);
+            result.put("modules", new ArrayList<>());
+            result.put("totalModules", 0);
+            return result;
+        }
+
+        if (report.affectedModules.isEmpty() && 
containerModules.nativeModules.isEmpty()
+                && containerModules.jvmModules.isEmpty()) {
+            getLog().info("No affected modules - using full build for safety");
+            result.put("incrementalBuild", false);
+            result.put("modules", new ArrayList<>());
+            result.put("totalModules", 0);
+            return result;
+        }
+
+        Set<String> affectedTests = 
extractAffectedTests(report.affectedModules);
+        affectedTests.addAll(containerModules.nativeModules);
+
+        result.put("incrementalBuild", true);
+        result.put("modules", new ArrayList<>(affectedTests));
+        result.put("totalModules", affectedTests.size());
+
+        getLog().info("Incremental build: " + affectedTests.size() + " 
affected modules");
+        return result;
+    }
+
+    /**
+     * Extracts affected integration test modules from Scalpel report.
+     * Includes both DIRECT and DOWNSTREAM changes - if Scalpel reports it as 
affected,
+     * we should test it.
+     */
+    private Set<String> extractAffectedTests(List<Map<String, Object>> 
affectedModules) {
+        Set<String> affectedTests = new LinkedHashSet<>();
+
+        for (Map<String, Object> module : affectedModules) {
+            String path = (String) module.get("path");
+            String category = (String) module.get("category");
+
+            // Handle integration-test-groups: 
integration-test-groups/<group>/... -> <group>-grouped
+            if (path != null && path.startsWith(integrationTestGroupsPrefix)) {
+                // Extract group name from: integration-test-groups/<group>/...
+                String remainder = 
path.substring(integrationTestGroupsPrefix.length());
+                String[] parts = remainder.split("/");
+                if (parts.length >= 1) {
+                    String groupName = parts[0]; // Get the group name
+                    String groupedModuleName = groupName + "-grouped";
+                    affectedTests.add(groupedModuleName);
+                    getLog().debug("Including grouped test: " + 
groupedModuleName + " (category: " + category + ")");
+                }
+                continue;
+            }
+
+            // Handle regular integration-tests
+            if (path != null && path.startsWith(nativeTestsPrefix)) {
+                // Include both DIRECT and DOWNSTREAM - if Scalpel detected 
it, test it
+                if ("DIRECT".equals(category) || 
"DOWNSTREAM".equals(category)) {
+                    // Extract test name: integration-tests/box -> box
+                    String testName = 
path.substring(nativeTestsPrefix.length());
+                    // Remove any trailing path components
+                    if (testName.contains("/")) {
+                        testName = testName.substring(0, 
testName.indexOf("/"));
+                    }
+                    affectedTests.add(testName);
+                    getLog().debug("Including test: " + testName + " 
(category: " + category + ")");
+                }
+            }
+        }
+
+        return affectedTests;
+    }
+
+    private static class ContainerAffectedModules {
+        final Set<String> nativeModules = new LinkedHashSet<>();
+        final Set<String> jvmModules = new LinkedHashSet<>();
+    }
+
+    /**
+     * Scans TestResource.java files for references to changed container image 
properties
+     * and returns the affected module names, split by test type.
+     * <p>
+     * Handles two cases:
+     * <ul>
+     * <li>Direct references in test modules (integration-tests/, 
integration-tests-jvm/, integration-test-groups/)</li>
+     * <li>References in shared support modules (integration-tests-support/) — 
resolved by finding which test
+     * modules depend on the affected support module via POM dependency 
grep</li>
+     * </ul>
+     */
+    private ContainerAffectedModules detectContainerAffectedModules() throws 
IOException {
+        ContainerAffectedModules result = new ContainerAffectedModules();
+
+        if (changedContainerProperties == null || 
changedContainerProperties.isBlank()) {
+            return result;
+        }
+
+        Set<String> changedProps = new LinkedHashSet<>();
+        for (String prop : changedContainerProperties.split(",")) {
+            String trimmed = prop.trim();
+            if (!trimmed.isEmpty()) {
+                changedProps.add(trimmed);
+            }
+        }
+
+        if (changedProps.isEmpty()) {
+            return result;
+        }
+
+        getLog().info("Scanning for changed container properties: " + 
changedProps);
+
+        // Scan integration-tests/ and integration-tests-jvm/ for direct 
references
+        for (String prefix : integrationTestDirs.split(",")) {
+            String trimmedPrefix = prefix.trim();
+            Path dir = projectRootDir.resolve(trimmedPrefix);
+            if (!Files.isDirectory(dir)) {
+                continue;
+            }
+
+            boolean isJvm = trimmedPrefix.equals(jvmTestsPrefix.trim());
+            scanTestResourceFiles(dir, changedProps, (moduleName) -> {
+                if (isJvm) {
+                    result.jvmModules.add(moduleName);
+                } else {
+                    result.nativeModules.add(moduleName);
+                }
+            });
+        }
+
+        // Scan integration-test-groups/ for direct references
+        Path groupsDir = 
projectRootDir.resolve(integrationTestGroupsPrefix.trim());
+        if (Files.isDirectory(groupsDir)) {
+            scanTestResourceFiles(groupsDir, changedProps, (moduleName) -> {
+                result.nativeModules.add(moduleName + "-grouped");
+            });
+        }
+
+        // Scan integration-tests-support/ for references in shared modules
+        Path supportDir = 
projectRootDir.resolve(integrationTestSupportPrefix.trim());
+        if (Files.isDirectory(supportDir)) {
+            Set<String> affectedSupportModules = new LinkedHashSet<>();
+            scanTestResourceFiles(supportDir, changedProps, 
affectedSupportModules::add);
+
+            if (!affectedSupportModules.isEmpty()) {
+                getLog().info("Container properties referenced in support 
modules: " + affectedSupportModules);
+                resolveSupportModuleDependents(affectedSupportModules, result);
+            }
+        }
+
+        if (!result.nativeModules.isEmpty()) {
+            getLog().info("Container property changes affect native test 
modules: " + result.nativeModules);
+        }
+        if (!result.jvmModules.isEmpty()) {
+            getLog().info("Container property changes affect JVM test modules: 
" + result.jvmModules);
+        }
+
+        return result;
+    }
+
+    @FunctionalInterface
+    private interface ModuleConsumer {
+        void accept(String moduleName);
+    }
+
+    /**
+     * Walks a directory for TestResource.java files containing any of the 
given property names.
+     * For each match, extracts the top-level module name and passes it to the 
consumer.
+     */
+    private void scanTestResourceFiles(Path dir, Set<String> changedProps, 
ModuleConsumer consumer) throws IOException {
+        try (Stream<Path> paths = Files.walk(dir)) {
+            paths.filter(p -> 
p.getFileName().toString().endsWith("TestResource.java"))
+                    .forEach(file -> {
+                        try {
+                            String content = Files.readString(file, 
StandardCharsets.UTF_8);
+                            for (String prop : changedProps) {
+                                if (content.contains("\"" + prop + "\"")) {
+                                    Path relative = dir.relativize(file);
+                                    String moduleName = 
relative.getName(0).toString();
+                                    consumer.accept(moduleName);
+                                    break;
+                                }
+                            }
+                        } catch (IOException e) {
+                            getLog().warn("Failed to read " + file + ": " + 
e.getMessage());
+                        }
+                    });
+        }
+    }
+
+    /**
+     * For each affected support module, finds test modules that depend on it 
by grepping
+     * their pom.xml files for the support module's artifactId.
+     */
+    private void resolveSupportModuleDependents(Set<String> supportModules, 
ContainerAffectedModules result)
+            throws IOException {
+
+        for (String supportModule : supportModules) {
+            String artifactId = "camel-quarkus-integration-tests-support-" + 
supportModule;
+            getLog().info("Resolving dependents of " + artifactId);
+
+            // Search integration-tests/*/pom.xml
+            for (String prefix : integrationTestDirs.split(",")) {
+                String trimmedPrefix = prefix.trim();
+                Path dir = projectRootDir.resolve(trimmedPrefix);
+                if (!Files.isDirectory(dir)) {
+                    continue;
+                }
+
+                boolean isJvm = trimmedPrefix.equals(jvmTestsPrefix.trim());
+                findDependentModules(dir, artifactId, (moduleName) -> {
+                    if (isJvm) {
+                        result.jvmModules.add(moduleName);
+                    } else {
+                        result.nativeModules.add(moduleName);
+                    }
+                });
+            }
+
+            // Search integration-test-groups/*/*/pom.xml
+            Path groupsDir = 
projectRootDir.resolve(integrationTestGroupsPrefix.trim());
+            if (Files.isDirectory(groupsDir)) {
+                findDependentModules(groupsDir, artifactId, (moduleName) -> {
+                    result.nativeModules.add(moduleName + "-grouped");
+                });
+            }
+        }
+    }
+
+    /**
+     * Finds modules under a directory whose pom.xml contains a dependency on 
the given artifactId.
+     */
+    private void findDependentModules(Path dir, String artifactId, 
ModuleConsumer consumer) throws IOException {
+        try (Stream<Path> paths = Files.walk(dir)) {
+            paths.filter(p -> p.getFileName().toString().equals("pom.xml"))
+                    .forEach(pomFile -> {
+                        try {
+                            String content = Files.readString(pomFile, 
StandardCharsets.UTF_8);
+                            if (content.contains(artifactId)) {
+                                Path relative = dir.relativize(pomFile);
+                                String moduleName = 
relative.getName(0).toString();
+                                consumer.accept(moduleName);
+                                getLog().debug("  " + moduleName + " depends 
on " + artifactId);
+                            }
+                        } catch (IOException e) {
+                            getLog().warn("Failed to read " + pomFile + ": " + 
e.getMessage());
+                        }
+                    });
+        }
+    }
+
+    private Map<String, Object> generateNativeMatrix(List<String> modules) 
throws MojoExecutionException {
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        if (modules == null || modules.isEmpty()) {
+            result.put("include", new ArrayList<>());
+            return result;
+        }
+
+        // Distribute modules across balanced groups
+        int moduleCount = modules.size();
+        int groups = Math.min(moduleCount, maxGroups);
+        int modulesPerGroup = (int) Math.ceil((double) moduleCount / groups);
+
+        List<Map<String, String>> include = new ArrayList<>();
+        for (int i = 0; i < groups; i++) {
+            int start = i * modulesPerGroup;
+            int end = Math.min(start + modulesPerGroup, moduleCount);
+
+            if (start < moduleCount) {
+                List<String> groupModules = modules.subList(start, end);
+                Map<String, String> group = new LinkedHashMap<>();
+                group.put("name", String.format("group-%02d", i + 1));
+                group.put("modules", String.join(",", groupModules));
+                include.add(group);
+            }
+        }
+
+        // Validate matrix size
+        if (include.size() > maxMatrixSize) {
+            throw new MojoExecutionException(
+                    "Native test matrix size (" + include.size() + ") exceeds 
maximum (" + maxMatrixSize + ")");
+        }
+
+        result.put("include", include);
+        getLog().info("Native test matrix: " + include.size() + " groups for " 
+ moduleCount + " modules");
+        return result;
+    }
+
+    private Map<String, Object> detectFunctionalScope(ScalpelReport report) {
+        Map<String, String> prefixToScope = new LinkedHashMap<>();
+        Map<String, Boolean> scope = new LinkedHashMap<>();
+
+        for (String entry : functionalScopeDirs.split(",")) {
+            String[] parts = entry.trim().split(":");
+            if (parts.length == 2) {
+                String prefix = parts[0].trim();
+                String scopeName = parts[1].trim();
+                prefixToScope.put(prefix, scopeName);
+                scope.put(scopeName, false);
+            }
+        }
+
+        if (report == null) {
+            scope.replaceAll((k, v) -> true);
+            return new LinkedHashMap<>(scope);
+        }
+
+        for (Map<String, Object> module : report.affectedModules) {
+            String category = (String) module.get("category");
+            if (!"DIRECT".equals(category)) {
+                continue;
+            }
+
+            String path = (String) module.get("path");
+            if (path == null) {
+                continue;
+            }
+
+            // Check each prefix and set corresponding scope flag
+            for (Map.Entry<String, String> entry : prefixToScope.entrySet()) {
+                if (path.startsWith(entry.getKey())) {
+                    scope.put(entry.getValue(), true);
+                }
+            }
+        }
+
+        getLog().info("Functional test scope: " + scope);
+
+        return new LinkedHashMap<>(scope);
+    }
+
+    private Map<String, Object> detectJvmTests(ScalpelReport report, 
ContainerAffectedModules containerModules) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("runTests", false);
+        result.put("modules", "");
+
+        if (report == null) {
+            result.put("runTests", true);
+            return result;
+        }
+
+        if (report.affectedModules.isEmpty()) {
+            return result;
+        }
+
+        Set<String> jvmModules = new LinkedHashSet<>();
+        for (Map<String, Object> module : report.affectedModules) {
+            String path = (String) module.get("path");
+            if (path != null && path.startsWith(jvmTestsPrefix)) {
+                String moduleName = path.substring(jvmTestsPrefix.length());
+                if (moduleName.contains("/")) {
+                    moduleName = moduleName.substring(0, 
moduleName.indexOf("/"));
+                }
+                jvmModules.add(moduleName);
+            }
+        }
+
+        jvmModules.addAll(containerModules.jvmModules);
+
+        if (!jvmModules.isEmpty()) {
+            result.put("runTests", true);
+            result.put("modules", String.join(",", jvmModules));
+            getLog().info("JVM-only tests: " + jvmModules.size() + " modules 
affected");
+        }
+
+        return result;
+    }
+
+    private boolean shouldRunExamples(ScalpelReport report) {
+        if (report == null || report.fullBuildTriggered || 
report.affectedModules.isEmpty()) {
+            return true;
+        }
+
+        for (Map<String, Object> module : report.affectedModules) {
+            String path = (String) module.get("path");
+            String category = (String) module.get("category");
+
+            // Only consider DIRECT changes
+            if (!"DIRECT".equals(category)) {
+                continue;
+            }
+
+            if (path != null && isExtensionPath(path)) {
+                getLog().info("Examples should run - extension affected: " + 
path);
+                return true;
+            }
+        }
+
+        getLog().info("Examples will be skipped - only integration tests 
affected");
+        return false;
+    }
+
+    private boolean isExtensionPath(String path) {
+        for (String prefix : extensionDirs.split(",")) {
+            if (path.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Writes output JSON to file.
+     */
+    private void writeOutput(Map<String, Object> data) throws IOException {
+        Files.createDirectories(outputFile.getParent());
+
+        String json;
+        if (outputCompact) {
+            json = jsonMapper.writeValueAsString(data);
+        } else {
+            json = 
jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(data);
+        }
+
+        Files.writeString(outputFile, json);
+        getLog().debug("Written output to: " + outputFile);
+    }
+}

Reply via email to