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

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

commit 3d9170e7e5a76239681d798cb8e617f45ae256ba
Author: Jinal Sarvaiya <[email protected]>
AuthorDate: Wed Nov 11 18:55:59 2026 +0000

    baseline evidence integrity and BOM version resolution
---
 .../camel/kit/service/BaselineTestResults.java     |  57 +++++++--
 .../org/apache/camel/kit/service/InitService.java  |  27 +++-
 .../main/resources/skills/camel-execute/SKILL.md   |  15 ++-
 .../camel/kit/service/BaselineTestResultsTest.java | 137 ++++++++++++++++++++-
 .../apache/camel/kit/service/InitServiceTest.java  |  21 +++-
 .../apache/camel/kit/graph/ProjectDiscovery.java   |   4 +-
 .../apache/camel/kit/graph/parser/PomParser.java   |  17 ++-
 .../camel/kit/graph/ProjectDiscoveryTest.java      |  57 +++++++++
 .../camel/kit/graph/parser/PomParserTest.java      |  55 ++++++++-
 9 files changed, 357 insertions(+), 33 deletions(-)

diff --git 
a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/BaselineTestResults.java
 
b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/BaselineTestResults.java
index d2519623..79f7e13b 100644
--- 
a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/BaselineTestResults.java
+++ 
b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/BaselineTestResults.java
@@ -42,13 +42,25 @@ final class BaselineTestResults {
     record Outcome(String status, String reason) {
     }
 
+    /**
+     * Collection result carrying both the outcomes and whether the collection 
was complete. If any report could not be
+     * parsed or read, {@code complete} is false and the caller must not treat 
the baseline as usable preservation
+     * evidence.
+     */
+    record CollectionResult(Map<String, Outcome> outcomes, boolean complete, 
int failedReportCount) {
+    }
+
     /**
      * Collects every test outcome from the {@code TEST-*.xml} reports below 
{@code projectRoot}'s
      * {@code target/surefire-reports} and {@code target/failsafe-reports} 
directories, including those of Maven
-     * modules. A report that cannot be parsed is skipped; the result then 
simply lacks those tests and the caller
-     * records that the evidence is incomplete.
+     * modules. Only reports modified after {@code afterTimestamp} are 
included, so stale reports from a previous
+     * invocation are rejected. A report that cannot be parsed is skipped; the 
result then simply lacks those tests and
+     * the caller records that the evidence is incomplete.
+     *
+     * @param afterTimestamp only reports modified after this instant are 
collected; use the instant immediately before
+     *                       starting the test process to ensure only reports 
from the current invocation are included
      */
-    static Map<String, Outcome> collect(Path projectRoot) throws IOException {
+    static CollectionResult collect(Path projectRoot, Instant afterTimestamp) 
throws IOException {
         Map<String, Outcome> outcomes = new TreeMap<>();
         List<Path> reports = new ArrayList<>();
         try (Stream<Path> paths = Files.walk(projectRoot)) {
@@ -60,26 +72,47 @@ final class BaselineTestResults {
                         return normalized.contains("/target/surefire-reports/")
                                 || 
normalized.contains("/target/failsafe-reports/");
                     })
+                    .filter(p -> {
+                        try {
+                            return 
Files.getLastModifiedTime(p).toInstant().isAfter(afterTimestamp);
+                        } catch (IOException e) {
+                            return false;
+                        }
+                    })
                     .sorted()
                     .forEach(reports::add);
         }
+        int failedReports = 0;
         for (Path report : reports) {
             try {
-                readReport(report, outcomes);
+                readReport(projectRoot, report, outcomes);
             } catch (Exception e) {
-                // An unreadable report is reported by the caller as 
incomplete evidence.
+                failedReports++;
             }
         }
-        return outcomes;
+        boolean complete = failedReports == 0;
+        return new CollectionResult(outcomes, complete, failedReports);
     }
 
-    private static void readReport(Path report, Map<String, Outcome> outcomes) 
throws Exception {
+    private static void readReport(Path projectRoot, Path report, Map<String, 
Outcome> outcomes) throws Exception {
         DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
         factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
         
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, 
true);
         factory.setXIncludeAware(false);
         factory.setExpandEntityReferences(false);
         var document = factory.newDocumentBuilder().parse(report.toFile());
+
+        // Extract module path from report path: e.g. 
"module-a/target/surefire-reports/TEST-X.xml" → "module-a"
+        // Root module reports are just "target/surefire-reports/TEST-X.xml" → 
"" (empty module)
+        String relativeReportPath = 
projectRoot.relativize(report).toString().replace('\\', '/');
+        String modulePrefix = "";
+        if (relativeReportPath.contains("/target/")) {
+            String modulePath = relativeReportPath.substring(0, 
relativeReportPath.indexOf("/target/"));
+            if (!modulePath.isEmpty()) {
+                modulePrefix = modulePath + ":";
+            }
+        }
+
         NodeList cases = document.getElementsByTagName("testcase");
         for (int i = 0; i < cases.getLength(); i++) {
             Element testCase = (Element) cases.item(i);
@@ -88,7 +121,15 @@ final class BaselineTestResults {
             if (name.isEmpty()) {
                 continue;
             }
-            String id = className.isEmpty() ? name : className + "#" + name;
+            String baseId = className.isEmpty() ? name : className + "#" + 
name;
+            String id = modulePrefix + baseId;
+
+            // Detect collisions: if the same test identity appears twice, 
fail the collection.
+            // This can only happen if reports are malformed or there's a bug 
in identity construction.
+            if (outcomes.containsKey(id)) {
+                throw new IllegalStateException(
+                        "Duplicate test identity '" + id + "' in " + report + 
"; baseline collection cannot continue");
+            }
             outcomes.put(id, outcomeOf(testCase));
         }
     }
diff --git 
a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/InitService.java
 
b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/InitService.java
index 0c6e72c6..c9992d60 100644
--- 
a/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/InitService.java
+++ 
b/kit/camel-kit-core/src/main/java/org/apache/camel/kit/service/InitService.java
@@ -4,6 +4,7 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
@@ -884,6 +885,8 @@ public class InitService {
             mvnw.toFile().setExecutable(true);
             // Send output to a file so the child can never block on a full 
pipe buffer
             output = Files.createTempFile("camel-kit-baseline-", ".log");
+            // Capture the timestamp before starting the test process so we 
can reject stale reports.
+            Instant testStartTime = Instant.now();
             Process testProcess = new ProcessBuilder()
                     .command("./mvnw", "test", "-B", "-q")
                     .directory(projectRoot.toFile())
@@ -901,13 +904,29 @@ public class InitService {
             }
 
             // Per-test outcomes and the revision they belong to: validation 
compares test identities, never totals.
-            var tests = BaselineTestResults.collect(projectRoot);
-            String status = testProcess.exitValue() == 0 ? "passed" : "failed";
-            String reason = testProcess.exitValue() == 0
+            // Only collect reports from this invocation; stale reports are 
rejected.
+            var collectionResult = BaselineTestResults.collect(projectRoot, 
testStartTime);
+            var tests = collectionResult.outcomes();
+            int exitCode = testProcess.exitValue();
+
+            // If no fresh reports were produced, the build likely failed 
before tests or tests were skipped.
+            // Exit 0 with no reports is not the same as "tests passed"; it 
means tests did not run.
+            if (tests.isEmpty() && exitCode == 0) {
+                writeBaselineStatus(baselineResultsFile, "not_run",
+                        "Tests did not run (no reports were produced despite 
successful Maven exit)", projectRoot);
+                config.setProperty("project.baselineTestStatus", "not_run");
+                return;
+            }
+
+            String status = exitCode == 0 ? "passed" : "failed";
+            String reason = exitCode == 0
                     ? "All tests passed"
                     : "Tests had failures (baseline state)";
             if (tests.isEmpty()) {
-                reason += "; no Surefire/Failsafe reports were found, so 
per-test outcomes are unavailable";
+                reason += "; no Surefire/Failsafe reports were produced, so 
per-test outcomes are unavailable";
+            } else if (!collectionResult.complete()) {
+                reason += "; " + collectionResult.failedReportCount() + " 
report(s) could not be parsed"
+                          + " (incomplete evidence, validation will report 
UNVERIFIED)";
             }
             writeBaselineFile(projectRoot, baselineResultsFile,
                     BaselineTestResults.toJson(status, reason, 
sourceRevision(projectRoot),
diff --git 
a/kit/camel-kit-core/src/main/resources/skills/camel-execute/SKILL.md 
b/kit/camel-kit-core/src/main/resources/skills/camel-execute/SKILL.md
index 11c01bd7..b4f16154 100644
--- a/kit/camel-kit-core/src/main/resources/skills/camel-execute/SKILL.md
+++ b/kit/camel-kit-core/src/main/resources/skills/camel-execute/SKILL.md
@@ -262,12 +262,15 @@ Read `.camel-kit/config.properties`:
 6. **Capture the test baseline BEFORE the first edit (existing projects 
only):**
    - Read `.camel-kit/baseline-test-results.json`. If `status` is `passed` or 
`failed`, `tests` is not empty, `dirty`
      is `false`, and `revision` equals the current `git rev-parse HEAD`, the 
baseline already exists. Do not touch it.
-   - Otherwise nothing has been edited yet, so this is the last moment a 
baseline can exist. Run the project's tests
-     with its own wrapper (`./mvnw test -B`), then write 
`.camel-kit/baseline-test-results.json` in the schema that
-     `camel-validate` Step 1 documents: `status`, `reason`, `timestamp`, 
`revision` (`git rev-parse HEAD`), `dirty`
-     (`true` if `git status --porcelain --untracked-files=no` is not empty), 
and every test under `tests` keyed
-     `class#method` with `passed`, `failed`, `error` or `skipped`, read from 
`target/surefire-reports/TEST-*.xml` and
-     `target/failsafe-reports/TEST-*.xml`.
+   - Otherwise nothing has been edited yet, so this is the last moment a 
baseline can exist. Note the current timestamp
+     (to reject stale reports), run the project's tests with its own wrapper 
(`./mvnw test -B`), then write
+     `.camel-kit/baseline-test-results.json` in the schema that 
`camel-validate` Step 1 documents: `status`, `reason`,
+     `timestamp`, `revision` (`git rev-parse HEAD`), `dirty` (`true` if `git 
status --porcelain --untracked-files=no`
+     is not empty), and every test under `tests` keyed `class#method` with 
`passed`, `failed`, `error` or `skipped`,
+     read from `target/surefire-reports/TEST-*.xml` and 
`target/failsafe-reports/TEST-*.xml` **modified after the
+     noted timestamp**. Reports older than the test invocation are stale and 
must be ignored. If the tests exit 0 but
+     produce no fresh reports, set `status` to `not_run` with reason "Tests 
did not run (no reports produced)" rather
+     than claiming they passed.
    - If the tests cannot be run (no wrapper, build broken, timeout), write the 
file with `status` `not_run` or
      `timeout` and the reason, and tell the user that baseline preservation 
will be reported as UNVERIFIED. Do not
      proceed as if a baseline existed.
diff --git 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/BaselineTestResultsTest.java
 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/BaselineTestResultsTest.java
index df004028..64fcb09b 100644
--- 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/BaselineTestResultsTest.java
+++ 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/BaselineTestResultsTest.java
@@ -2,6 +2,9 @@ package org.apache.camel.kit.service;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.Map;
 
 import com.fasterxml.jackson.databind.JsonNode;
@@ -42,15 +45,19 @@ class BaselineTestResultsTest {
                 </testsuite>
                 """);
 
-        Map<String, BaselineTestResults.Outcome> tests = 
BaselineTestResults.collect(tempDir);
+        var result = BaselineTestResults.collect(tempDir, Instant.MIN);
 
+        assertTrue(result.complete(), "all reports should be parseable");
+        Map<String, BaselineTestResults.Outcome> tests = result.outcomes();
         assertEquals(5, tests.size());
+        // Root module tests have no prefix
         assertEquals("passed", 
tests.get("com.example.OrderTest#passes").status());
         assertEquals("failed", 
tests.get("com.example.OrderTest#fails").status());
         assertEquals("expected 1 but was 2", 
tests.get("com.example.OrderTest#fails").reason());
         assertEquals("error", 
tests.get("com.example.OrderTest#errors").status());
         assertEquals("skipped", 
tests.get("com.example.OrderTest#skipped").status());
-        assertEquals("passed", 
tests.get("com.example.ModuleTest#passes").status());
+        // Module tests have module prefix
+        assertEquals("passed", 
tests.get("module-a:com.example.ModuleTest#passes").status());
     }
 
     @Test
@@ -60,8 +67,11 @@ class BaselineTestResultsTest {
                 """);
         writeReport(tempDir.resolve("target/surefire-reports"), 
"com.example.Broken", "<testsuite><testcase");
 
-        assertTrue(BaselineTestResults.collect(tempDir).isEmpty(),
+        var result = BaselineTestResults.collect(tempDir, Instant.MIN);
+        assertTrue(result.outcomes().isEmpty(),
                 "only well-formed reports below target/surefire-reports or 
target/failsafe-reports count");
+        assertFalse(result.complete(), "broken report should mark collection 
as incomplete");
+        assertEquals(1, result.failedReportCount());
     }
 
     @Test
@@ -71,7 +81,126 @@ class BaselineTestResultsTest {
                 <testsuite><testcase classname="com.example.XxeTest" 
name="&x;"/></testsuite>
                 """);
 
-        assertTrue(BaselineTestResults.collect(tempDir).isEmpty(), "external 
entities must never be resolved");
+        var result = BaselineTestResults.collect(tempDir, Instant.MIN);
+        assertTrue(result.outcomes().isEmpty(), "external entities must never 
be resolved");
+        assertFalse(result.complete(), "XXE attack attempt should fail parsing 
and mark incomplete");
+    }
+
+    @Test
+    void rejectsStaleReportsFromPreviousInvocations() throws Exception {
+        // Write a stale report from a previous run (dated 2020)
+        Path staleDir = tempDir.resolve("target/surefire-reports");
+        Files.createDirectories(staleDir);
+        Path staleReport = staleDir.resolve("TEST-com.example.OldTest.xml");
+        Files.writeString(staleReport, """
+                <testsuite name="com.example.OldTest" tests="1">
+                  <testcase classname="com.example.OldTest" name="oldFailure">
+                    <failure message="old bug from 2020"/>
+                  </testcase>
+                </testsuite>
+                """);
+        // Backdate it to 2020
+        Instant old = Instant.parse("2020-01-01T00:00:00Z");
+        Files.setLastModifiedTime(staleReport, FileTime.from(old));
+
+        // Simulate capturing timestamp before running tests
+        Instant testStartTime = Instant.now().minus(1, ChronoUnit.SECONDS);
+
+        // Simulate a test run that produces no new reports (build failure, 
tests skipped, etc.)
+        // Only the stale report exists, but it predates testStartTime
+
+        var result = BaselineTestResults.collect(tempDir, testStartTime);
+
+        assertTrue(result.outcomes().isEmpty(),
+                "stale reports from before the test invocation must be 
rejected");
+        assertTrue(result.complete(), "no parse failures when reports are just 
stale");
+    }
+
+    @Test
+    void includesFreshReportsAndRejectsStaleOnes() throws Exception {
+        // Write a stale report from a previous run
+        Path reportDir = tempDir.resolve("target/surefire-reports");
+        Files.createDirectories(reportDir);
+        Path staleReport = reportDir.resolve("TEST-com.example.OldTest.xml");
+        Files.writeString(staleReport, """
+                <testsuite name="com.example.OldTest" tests="1">
+                  <testcase classname="com.example.OldTest" name="oldTest">
+                    <failure message="stale"/>
+                  </testcase>
+                </testsuite>
+                """);
+        Instant old = Instant.parse("2020-01-01T00:00:00Z");
+        Files.setLastModifiedTime(staleReport, FileTime.from(old));
+
+        // Capture timestamp before simulated test run
+        Instant testStartTime = Instant.now().minus(1, ChronoUnit.SECONDS);
+
+        // Write a fresh report from the current invocation
+        Path freshReport = reportDir.resolve("TEST-com.example.NewTest.xml");
+        Files.writeString(freshReport, """
+                <testsuite name="com.example.NewTest" tests="1">
+                  <testcase classname="com.example.NewTest" name="newTest"/>
+                </testsuite>
+                """);
+
+        var result = BaselineTestResults.collect(tempDir, testStartTime);
+        Map<String, BaselineTestResults.Outcome> collected = result.outcomes();
+
+        assertTrue(result.complete(), "both reports are well-formed");
+        assertEquals(1, collected.size(), "only fresh reports from current 
invocation are included");
+        assertNotNull(collected.get("com.example.NewTest#newTest"));
+        assertNull(collected.get("com.example.OldTest#oldTest"), "stale report 
must be excluded");
+    }
+
+    @Test
+    void marksCollectionIncompleteWhenMixingValidAndInvalidReports() throws 
Exception {
+        Path reportDir = tempDir.resolve("target/surefire-reports");
+        Files.createDirectories(reportDir);
+
+        // Write one valid report
+        
Files.writeString(reportDir.resolve("TEST-com.example.PassingTest.xml"), """
+                <testsuite name="com.example.PassingTest" tests="1">
+                  <testcase classname="com.example.PassingTest" 
name="validTest"/>
+                </testsuite>
+                """);
+
+        // Write one malformed report
+        
Files.writeString(reportDir.resolve("TEST-com.example.BrokenTest.xml"), 
"<testsuite><testcase");
+
+        var result = BaselineTestResults.collect(tempDir, Instant.MIN);
+
+        assertEquals(1, result.outcomes().size(), "valid report should be 
collected");
+        
assertNotNull(result.outcomes().get("com.example.PassingTest#validTest"));
+        assertFalse(result.complete(), "collection must be marked incomplete 
when any report fails");
+        assertEquals(1, result.failedReportCount(), "one malformed report");
+    }
+
+    @Test
+    void includesModuleProvenanceInTestIdentityToPreventCollisions() throws 
Exception {
+        // Two modules with the same test class and method but different 
outcomes
+        writeReport(tempDir.resolve("module-a/target/surefire-reports"), 
"demo.OldTest", """
+                <testsuite name="demo.OldTest" tests="1">
+                  <testcase classname="demo.OldTest" name="oldA">
+                    <failure message="failed in module-a"/>
+                  </testcase>
+                </testsuite>
+                """);
+        writeReport(tempDir.resolve("module-b/target/surefire-reports"), 
"demo.OldTest", """
+                <testsuite name="demo.OldTest" tests="1">
+                  <testcase classname="demo.OldTest" name="oldA"/>
+                </testsuite>
+                """);
+
+        var result = BaselineTestResults.collect(tempDir, Instant.MIN);
+
+        assertTrue(result.complete());
+        Map<String, BaselineTestResults.Outcome> tests = result.outcomes();
+        assertEquals(2, tests.size(), "both modules' tests should be 
preserved");
+        // Module-a test failed
+        assertEquals("failed", 
tests.get("module-a:demo.OldTest#oldA").status());
+        assertEquals("failed in module-a", 
tests.get("module-a:demo.OldTest#oldA").reason());
+        // Module-b test passed
+        assertEquals("passed", 
tests.get("module-b:demo.OldTest#oldA").status());
     }
 
     @Test
diff --git 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/InitServiceTest.java
 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/InitServiceTest.java
index 06eacf76..3cb6c9d4 100644
--- 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/InitServiceTest.java
+++ 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/service/InitServiceTest.java
@@ -1355,6 +1355,19 @@ class InitServiceTest {
     @EnabledOnOs({OS.LINUX, OS.MAC})
     void recordBaselineRecordsPassedWhenTheProjectTestsPass() throws Exception 
{
         Path targetDir = projectWithFakeWrapper("baseline-pass", 0);
+        // The fake wrapper must write reports so they're created during the 
test invocation.
+        Files.writeString(targetDir.resolve("mvnw"), """
+                #!/bin/sh
+                touch mvnw-was-run
+                mkdir -p target/surefire-reports
+                cat > target/surefire-reports/TEST-com.example.PassTest.xml 
<<'XML'
+                <testsuite name="com.example.PassTest" tests="1">
+                  <testcase classname="com.example.PassTest" 
name="passesTest"/>
+                </testsuite>
+                XML
+                exit 0
+                """);
+        targetDir.resolve("mvnw").toFile().setExecutable(true);
 
         new InitService().initialize(recordingBaselineRequest(targetDir));
 
@@ -1417,10 +1430,12 @@ class InitServiceTest {
 
         var baseline = new com.fasterxml.jackson.databind.ObjectMapper()
                 
.readTree(Files.readString(targetDir.resolve(".camel-kit/baseline-test-results.json")));
-        assertEquals("passed", baseline.get("status").asText());
+        // Exit 0 with no fresh reports means tests did not run (build failed 
before tests, tests skipped, etc.)
+        // This is distinct from "tests passed" and must be recorded as 
"not_run" per Finding 1.
+        assertEquals("not_run", baseline.get("status").asText());
         assertEquals(0, baseline.get("testCount").asInt());
-        assertTrue(baseline.get("reason").asText().contains("per-test outcomes 
are unavailable"),
-                "an empty test list must be flagged so validation reports 
unavailable evidence");
+        assertTrue(baseline.get("reason").asText().contains("no reports were 
produced"),
+                "exit 0 without reports must explain that tests did not run");
     }
 
     @Test
diff --git 
a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/ProjectDiscovery.java
 
b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/ProjectDiscovery.java
index f1cf4ce9..e4e7655d 100644
--- 
a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/ProjectDiscovery.java
+++ 
b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/ProjectDiscovery.java
@@ -131,8 +131,10 @@ public final class ProjectDiscovery {
     }
 
     private static boolean isCamelBom(String groupId, String artifactId) {
+        // Only Camel core BOMs (org.apache.camel group) represent Camel core 
versions.
+        // Camel Quarkus extension BOMs (org.apache.camel.quarkus group) have 
independent versioning.
         return artifactId != null && artifactId.startsWith("camel-") && 
artifactId.endsWith("-bom")
-                && groupId != null && groupId.startsWith("org.apache.camel");
+                && "org.apache.camel".equals(groupId);
     }
 
     private static void addVersion(Map<String, Set<String>> versions, String 
version, String source) {
diff --git 
a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java
 
b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java
index d8687768..031f22a1 100644
--- 
a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java
+++ 
b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java
@@ -74,29 +74,32 @@ public class PomParser implements GraphParser {
 
             // Version evidence from the parent and from dependencyManagement 
(imported BOMs). These carry the platform
             // version (Spring Boot, Quarkus platform, Camel BOM) that 
dependencies without a version rely on. They are
-            // recorded as separate nodes so they never masquerade as a 
dependency of the project.
+            // recorded as separate nodes so they never masquerade as a 
dependency of the project. Versions are resolved
+            // against the declaring POM's own properties to prevent 
cross-module property collision.
             String pomRelative = projectRoot.relativize(pomFile).toString();
             if (model.getParent() != null) {
+                String parentVersion = 
resolveProperty(model.getParent().getVersion(), model);
                 graph.addNode(new GraphNode(
                         "maven-parent:" + model.getParent().getGroupId() + ":" 
+ model.getParent().getArtifactId(),
                         NodeType.CONFIG_PROPERTY,
                         Map.of("kind", "parent",
                                 "groupId", model.getParent().getGroupId(),
                                 "artifactId", 
model.getParent().getArtifactId(),
-                                "version", 
String.valueOf(model.getParent().getVersion()),
+                                "version", parentVersion != null ? 
parentVersion : "unknown",
                                 "file", pomRelative)));
             }
             DependencyManagement management = model.getDependencyManagement();
             if (management != null) {
                 for (Dependency managed : management.getDependencies()) {
-                    String managedVersion = managed.getVersion() != null ? 
managed.getVersion() : "managed";
+                    String rawVersion = managed.getVersion();
+                    String resolvedVersion = rawVersion != null ? 
resolveProperty(rawVersion, model) : "managed";
                     graph.addNode(new GraphNode(
                             "maven-managed:" + managed.getGroupId() + ":" + 
managed.getArtifactId(),
                             NodeType.CONFIG_PROPERTY,
                             Map.of("kind", "managed",
                                     "groupId", managed.getGroupId(),
                                     "artifactId", managed.getArtifactId(),
-                                    "version", managedVersion,
+                                    "version", resolvedVersion,
                                     "scope", managed.getScope() != null ? 
managed.getScope() : "compile",
                                     "type", managed.getType() != null ? 
managed.getType() : "jar",
                                     "file", pomRelative)));
@@ -145,8 +148,10 @@ public class PomParser implements GraphParser {
         DependencyManagement management = model.getDependencyManagement();
         if (management != null) {
             for (Dependency managed : management.getDependencies()) {
-                if ("pom".equals(managed.getType()) && managed.getGroupId() != 
null
-                        && managed.getGroupId().startsWith("org.apache.camel")
+                // Only Camel core BOMs (org.apache.camel group) represent 
Camel core versions.
+                // Camel Quarkus extension BOMs (org.apache.camel.quarkus 
group) have independent versioning.
+                if ("pom".equals(managed.getType()) && 
"org.apache.camel".equals(managed.getGroupId())
+                        && managed.getArtifactId() != null
                         && managed.getArtifactId().startsWith("camel-") && 
managed.getArtifactId().endsWith("-bom")) {
                     addCamelVersionEvidence(graph, pomRelative, "imported BOM 
" + managed.getArtifactId(),
                             managed.getVersion(), model);
diff --git 
a/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/ProjectDiscoveryTest.java
 
b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/ProjectDiscoveryTest.java
index 7384e0f9..309cd8fa 100644
--- 
a/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/ProjectDiscoveryTest.java
+++ 
b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/ProjectDiscoveryTest.java
@@ -665,4 +665,61 @@ class ProjectDiscoveryTest {
 
         assertEquals("4.8.0", 
ProjectDiscovery.discover(reactor).camelVersion());
     }
+
+    @Test
+    void camelQuarkusExtensionBomVersionIsNotCamelCoreVersion() {
+        // Camel Quarkus BOM 3.15.0 manages Camel core 4.8.0, so 
org.apache.camel.quarkus:camel-quarkus-bom:3.15.0
+        // must NOT be treated as evidence of Camel core 3.15.0
+        ProjectGraph graph = new ProjectGraph();
+        graph.addNode(new GraphNode(
+                "maven-property:camel.version",
+                NodeType.CONFIG_PROPERTY,
+                Map.of("value", "4.8.0", "file", "pom.xml")));
+        graph.addNode(new GraphNode(
+                "maven-managed:org.apache.camel.quarkus:camel-quarkus-bom",
+                NodeType.CONFIG_PROPERTY,
+                Map.of(
+                        "kind", "managed",
+                        "groupId", "org.apache.camel.quarkus",
+                        "artifactId", "camel-quarkus-bom",
+                        "version", "3.15.0",
+                        "file", "pom.xml")));
+
+        ProjectDiscovery.DiscoveryResult result = 
ProjectDiscovery.discover(graph);
+
+        assertEquals("4.8.0", result.camelVersion(), "only the camel.version 
property should be used");
+        assertFalse(result.hasConflict(), "Camel Quarkus BOM 3.15.0 should not 
create false conflict with Camel 4.8.0");
+        assertTrue(result.allCamelVersions().contains("4.8.0"));
+        assertFalse(result.allCamelVersions().contains("3.15.0"),
+                "Camel Quarkus extension BOM version must not appear as Camel 
core version");
+    }
+
+    @Test
+    void camelQuarkusExtensionBomAloneDoesNotProvideCamelCoreVersion() {
+        // Without explicit Camel version, org.apache.camel.quarkus BOM should 
NOT be inferred as Camel core version
+        ProjectGraph graph = new ProjectGraph();
+        graph.addNode(new GraphNode(
+                "maven-managed:org.apache.camel.quarkus:camel-quarkus-bom",
+                NodeType.CONFIG_PROPERTY,
+                Map.of(
+                        "kind", "managed",
+                        "groupId", "org.apache.camel.quarkus",
+                        "artifactId", "camel-quarkus-bom",
+                        "version", "3.15.0",
+                        "file", "pom.xml")));
+        graph.addNode(new GraphNode(
+                "maven:org.apache.camel.quarkus:camel-quarkus-core",
+                NodeType.MAVEN_ARTIFACT,
+                Map.of(
+                        "groupId", "org.apache.camel.quarkus",
+                        "artifactId", "camel-quarkus-core",
+                        "version", "managed")));
+
+        ProjectDiscovery.DiscoveryResult result = 
ProjectDiscovery.discover(graph);
+
+        assertNull(result.camelVersion(),
+                "Camel Quarkus extension BOM alone should not provide Camel 
core version");
+        assertTrue(result.allCamelVersions().isEmpty(),
+                "no Camel core version evidence should be present");
+    }
 }
diff --git 
a/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/parser/PomParserTest.java
 
b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/parser/PomParserTest.java
index b3fc46b9..9bb04725 100644
--- 
a/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/parser/PomParserTest.java
+++ 
b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/parser/PomParserTest.java
@@ -92,8 +92,61 @@ class PomParserTest {
         var bom = 
local.getNode("maven-managed:org.apache.camel.springboot:camel-spring-boot-bom");
         assertNotNull(bom);
         assertEquals("managed", bom.properties().get("kind"));
-        assertEquals("${camel.version}", bom.properties().get("version"));
+        assertEquals("4.8.0", bom.properties().get("version"),
+                "version should be resolved against declaring POM's 
properties");
         assertEquals("pom", bom.properties().get("type"));
         assertEquals("import", bom.properties().get("scope"));
     }
+
+    @Test
+    void 
resolvesVersionsAgainstDeclaringModulePropertiesToPreventCrossModuleCollision(@TempDir
 Path root)
+            throws Exception {
+        // Module a imports Camel Spring Boot BOM ${bom.version} with 
bom.version=4.8.0
+        Files.createDirectories(root.resolve("module-a"));
+        Files.writeString(root.resolve("module-a/pom.xml"), """
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                  <modelVersion>4.0.0</modelVersion>
+                  <groupId>com.example</groupId>
+                  <artifactId>module-a</artifactId>
+                  <version>1.0.0</version>
+                  <properties>
+                    <bom.version>4.8.0</bom.version>
+                  </properties>
+                  <dependencyManagement>
+                    <dependencies>
+                      <dependency>
+                        <groupId>org.apache.camel.springboot</groupId>
+                        <artifactId>camel-spring-boot-bom</artifactId>
+                        <version>${bom.version}</version>
+                        <type>pom</type>
+                        <scope>import</scope>
+                      </dependency>
+                    </dependencies>
+                  </dependencyManagement>
+                </project>
+                """);
+
+        // Module b has bom.version=1.0.0 (unrelated, no Camel)
+        Files.createDirectories(root.resolve("module-b"));
+        Files.writeString(root.resolve("module-b/pom.xml"), """
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                  <modelVersion>4.0.0</modelVersion>
+                  <groupId>com.example</groupId>
+                  <artifactId>module-b</artifactId>
+                  <version>1.0.0</version>
+                  <properties>
+                    <bom.version>1.0.0</bom.version>
+                  </properties>
+                </project>
+                """);
+
+        ProjectGraph reactor = new ProjectGraph();
+        new PomParser().parse(root, reactor);
+
+        var bom = 
reactor.getNode("maven-managed:org.apache.camel.springboot:camel-spring-boot-bom");
+        assertNotNull(bom, "Camel Spring Boot BOM should be recorded");
+        assertEquals("4.8.0", bom.properties().get("version"),
+                "BOM version should be resolved from module-a's bom.version 
property, not module-b's");
+        assertEquals("module-a/pom.xml", bom.properties().get("file"));
+    }
 }

Reply via email to