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")); + } }
