modules = new ArrayList<>();
+ private Path projectBaseDir;
+ private String projectKey = DEFAULT_PROJECT_KEY;
+
+ private ScannerVerifierAnalysisBuilder() {
+ serverContextBuilder = SonarServerContext.builder()
+ .withProduct(SonarServerContext.Product.SERVER)
+ .withEngineVersion(EngineVersion.latestRelease())
+ .withLanguage("java", "Java", ".java")
+ .withPlugin(JAVA_PLUGIN_PATH);
+ }
+
+ public static ScannerVerifierAnalysisBuilder newAnalysis(Path projectBaseDir) {
+ return new ScannerVerifierAnalysisBuilder()
+ .withProjectBaseDir(projectBaseDir)
+ .withProjectKey("project");
+ }
+
+ public ScannerVerifierAnalysisBuilder withProject(ScannerVerifierProjectBuilder project) {
+ serverContextBuilder.withProjectContext(project.build());
+ projectScannerProperties.putAll(project.toScannerProperties());
+ modules.addAll(project.modules());
+ return this;
+ }
+
+ private ScannerVerifierAnalysisBuilder withProjectBaseDir(Path projectBaseDir) {
+ this.projectBaseDir = projectBaseDir;
+ return this;
+ }
+
+ private ScannerVerifierAnalysisBuilder withProjectKey(String projectKey) {
+ this.projectKey = projectKey;
+ return this;
+ }
+
+ protected SonarServerContext buildServerContext() {
+ createModuleDirectories();
+ return serverContextBuilder.build();
+ }
+
+ @VisibleForTesting
+ protected void createModuleDirectories() {
+ for (ModuleBuilder module : modules) {
+ Path moduleDir = projectBaseDir.resolve(module.name());
+ Path srcDir = moduleDir.resolve("src");
+ try {
+ Files.createDirectories(srcDir);
+ for (Path inputFile : module.inputFiles()) {
+ Files.copy(inputFile, srcDir.resolve(inputFile.getFileName()), StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to set up module directory: " + moduleDir, e);
+ }
+ }
+ }
+
+ protected ScannerInput buildScannerInput() {
+ var builder = ScannerInput.create(projectKey, projectBaseDir);
+ if (!projectScannerProperties.isEmpty()) {
+ builder.withScannerProperties(projectScannerProperties);
+ }
+ return builder.build();
+ }
+}
diff --git a/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilder.java b/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilder.java
new file mode 100644
index 00000000000..391e8833795
--- /dev/null
+++ b/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilder.java
@@ -0,0 +1,162 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.ActiveRule;
+import com.sonarsource.scanner.integrationtester.dsl.SonarProjectContext;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+/**
+ * Builder to construct a {@link SonarProjectContext} and module-level scanner properties
+ * for multi-module project analysis.
+ *
+ * Usage example:
+ *
{@code
+ * var project = ScannerVerifierProjectBuilder.create()
+ * .withActiveRules(activeRules)
+ * .addModule("moduleA")
+ * .withInputFiles(Path.of("src/A.java"))
+ * .withBinaries(Path.of("target/classes"))
+ * .endModule()
+ * .addModule("moduleB")
+ * .withInputFiles(Path.of("src/B.java"))
+ * .withBinaries(Path.of("target/classes"))
+ * .withDependencies("moduleA")
+ * .endModule();
+ *
+ * SonarProjectContext context = project.build();
+ * Map properties = project.toScannerProperties();
+ * }
+ *
+ * When a module declares a dependency on another module via {@link ModuleBuilder#withDependencies(String...)},
+ * the dependent module's binaries are automatically added to the depending module's {@code sonar.java.libraries}.
+ */
+public class ScannerVerifierProjectBuilder {
+
+ private final List activeRules = new ArrayList<>();
+ private final Map modules = new LinkedHashMap<>();
+
+ private ScannerVerifierProjectBuilder() {
+ }
+
+ public static ScannerVerifierProjectBuilder newProject() {
+ return new ScannerVerifierProjectBuilder();
+ }
+
+ public ScannerVerifierProjectBuilder withActiveRules(List rules) {
+ activeRules.addAll(rules);
+ return this;
+ }
+
+ /**
+ * Adds an active rule by key (e.g. {@code "S100"}), automatically resolving
+ * name and severity from the sonar-java-plugin rule metadata.
+ */
+ public ScannerVerifierProjectBuilder withActiveRule(String ruleKey) {
+ activeRules.add(JavaRuleMetadata.activeRule(ruleKey));
+ return this;
+ }
+
+ public ModuleBuilder addModule(String name) {
+ Objects.requireNonNull(name, "Module name cannot be null");
+ if (modules.containsKey(name)) {
+ throw new IllegalArgumentException("Module '" + name + "' already exists");
+ }
+ var module = new ModuleBuilder(name, this);
+ modules.put(name, module);
+ return module;
+ }
+
+ /**
+ * Builds a {@link SonarProjectContext} with the configured active rules.
+ */
+ public SonarProjectContext build() {
+ validateDependencies();
+ return SonarProjectContext.builder()
+ .withActiveRules(activeRules)
+ .build();
+ }
+
+ /**
+ * Generates scanner properties for multi-module project configuration.
+ *
+ * Produces properties such as {@code sonar.modules}, and for each module:
+ * {@code .sonar.sources}, {@code .sonar.java.binaries},
+ * and {@code .sonar.java.libraries} (including resolved inter-module dependencies).
+ */
+ public Map toScannerProperties() {
+ validateDependencies();
+ Map properties = new LinkedHashMap<>();
+ if (modules.isEmpty()) {
+ return properties;
+ }
+
+ properties.put("sonar.modules", String.join(",", modules.keySet()));
+
+ for (ModuleBuilder module : modules.values()) {
+ String prefix = module.name() + ".";
+
+ if (!module.inputFiles().isEmpty()) {
+ properties.put(prefix + "sonar.sources", "src");
+ }
+
+ if (!module.binaries().isEmpty()) {
+ properties.put(prefix + "sonar.java.binaries",
+ module.binaries().stream().map(Path::toString).collect(Collectors.joining(",")));
+ }
+
+ List allLibraries = new ArrayList<>();
+ module.libraries().stream().map(Path::toString).forEach(allLibraries::add);
+
+ // Resolve inter-module dependencies: dependent module binaries become libraries
+ for (String dep : module.dependencies()) {
+ ModuleBuilder depModule = modules.get(dep);
+ depModule.binaries().stream().map(Path::toString).forEach(allLibraries::add);
+ }
+
+ if (!allLibraries.isEmpty()) {
+ properties.put(prefix + "sonar.java.libraries",
+ String.join(",", allLibraries));
+ }
+ }
+
+ return properties;
+ }
+
+ Collection modules() {
+ return modules.values();
+ }
+
+ private void validateDependencies() {
+ for (ModuleBuilder module : modules.values()) {
+ for (String dep : module.dependencies()) {
+ if (!modules.containsKey(dep)) {
+ throw new IllegalStateException(
+ "Module '" + module.name() + "' depends on unknown module '" + dep + "'");
+ }
+ }
+ }
+ }
+}
diff --git a/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierResult.java b/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierResult.java
new file mode 100644
index 00000000000..4865c801c29
--- /dev/null
+++ b/java-checks-testkit/src/main/java/org/sonar/java/checks/verifier/sit/ScannerVerifierResult.java
@@ -0,0 +1,41 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.ScannerResult;
+import com.sonarsource.scanner.integrationtester.dsl.ScannerResultSuccess;
+import com.sonarsource.scanner.integrationtester.dsl.issue.FileIssue;
+
+import java.util.List;
+
+public class ScannerVerifierResult {
+
+ private final ScannerResult scannerResult;
+
+ protected ScannerVerifierResult(ScannerResult scannerResult) {
+ this.scannerResult = scannerResult;
+ assert scannerResult.exitCode() != 1 : "Scanner execution failed with exit code 1, expected 0. Check the logs for more details.";
+ }
+
+ public List getAllIssues() {
+ ScannerResultSuccess successResult = (ScannerResultSuccess) scannerResult;
+ return successResult.scannerOutputReader().getFiles().stream()
+ .flatMap(file -> file.getIssues().stream())
+ .toList();
+ }
+
+}
diff --git a/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/JavaRuleMetadataTest.java b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/JavaRuleMetadataTest.java
new file mode 100644
index 00000000000..1c3c73ecb28
--- /dev/null
+++ b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/JavaRuleMetadataTest.java
@@ -0,0 +1,52 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.ActiveRule;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+class JavaRuleMetadataTest {
+
+ @Test
+ void activeRule_loads_title_and_severity() {
+ ActiveRule rule = JavaRuleMetadata.activeRule("S100");
+
+ assertThat(rule.ruleKey().repository()).isEqualTo("java");
+ assertThat(rule.ruleKey().rule()).isEqualTo("S100");
+ assertThat(rule.ruleName()).isEqualTo("Method names should comply with a naming convention");
+ assertThat(rule.severity()).isEqualTo(ActiveRule.Severity.MINOR);
+ assertThat(rule.languageKey()).isEqualTo("java");
+ }
+
+ @Test
+ void activeRule_caches_results() {
+ ActiveRule first = JavaRuleMetadata.activeRule("S101");
+ ActiveRule second = JavaRuleMetadata.activeRule("S101");
+
+ assertThat(first).isSameAs(second);
+ }
+
+ @Test
+ void activeRule_throws_for_unknown_rule() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> JavaRuleMetadata.activeRule("S999999"))
+ .withMessageContaining("S999999");
+ }
+}
diff --git a/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ModuleBuilderTest.java b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ModuleBuilderTest.java
new file mode 100644
index 00000000000..884da98ae5e
--- /dev/null
+++ b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ModuleBuilderTest.java
@@ -0,0 +1,118 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import java.nio.file.Path;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ModuleBuilderTest {
+
+ @Test
+ void endModule_returns_project_builder() {
+ ScannerVerifierProjectBuilder projectBuilder = ScannerVerifierProjectBuilder.newProject();
+ ModuleBuilder moduleBuilder = projectBuilder.addModule("mod");
+
+ assertThat(moduleBuilder.endModule()).isSameAs(projectBuilder);
+ }
+
+ @Test
+ void withInputFiles_adds_files() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .withInputFiles(Path.of("src/A.java"), Path.of("src/B.java"))
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsEntry("mod.sonar.sources", "src");
+ }
+
+ @Test
+ void withBinaries_adds_binaries() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .withBinaries(Path.of("target/classes"), Path.of("target/extra"))
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsEntry("mod.sonar.java.binaries", "target/classes,target/extra");
+ }
+
+ @Test
+ void withLibraries_adds_libraries() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .withLibraries(Path.of("lib/a.jar"), Path.of("lib/b.jar"))
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsEntry("mod.sonar.java.libraries", "lib/a.jar,lib/b.jar");
+ }
+
+ @Test
+ void withDependencies_resolves_dependent_binaries_as_libraries() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("core")
+ .withInputFiles(Path.of("src/Core.java"))
+ .withBinaries(Path.of("core/target/classes"))
+ .endModule()
+ .addModule("app")
+ .withInputFiles(Path.of("src/App.java"))
+ .withDependencies("core")
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsEntry("app.sonar.java.libraries", "core/target/classes");
+ }
+
+ @Test
+ void withDependencies_merges_with_explicit_libraries() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("core")
+ .withBinaries(Path.of("core/target/classes"))
+ .endModule()
+ .addModule("app")
+ .withLibraries(Path.of("lib/ext.jar"))
+ .withDependencies("core")
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props.get("app.sonar.java.libraries"))
+ .contains("lib/ext.jar")
+ .contains("core/target/classes");
+ }
+
+ @Test
+ void multiple_calls_accumulate() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .withInputFiles(Path.of("src/A.java"))
+ .withInputFiles(Path.of("src/B.java"))
+ .withBinaries(Path.of("target/classes"))
+ .withBinaries(Path.of("target/extra"))
+ .withLibraries(Path.of("lib/a.jar"))
+ .withLibraries(Path.of("lib/b.jar"))
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props.get("mod.sonar.sources")).isEqualTo("src");
+ assertThat(props.get("mod.sonar.java.binaries")).isEqualTo("target/classes,target/extra");
+ assertThat(props.get("mod.sonar.java.libraries")).isEqualTo("lib/a.jar,lib/b.jar");
+ }
+}
diff --git a/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierAnalysisBuilderTest.java b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierAnalysisBuilderTest.java
new file mode 100644
index 00000000000..66167c66607
--- /dev/null
+++ b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierAnalysisBuilderTest.java
@@ -0,0 +1,189 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.ActiveRule;
+import com.sonarsource.scanner.integrationtester.dsl.RuleKey;
+import com.sonarsource.scanner.integrationtester.dsl.ScannerInput;
+import com.sonarsource.scanner.integrationtester.dsl.SonarServerContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ScannerVerifierAnalysisBuilderTest {
+
+ private static final ActiveRule RULE = ActiveRule.builder()
+ .withKey(RuleKey.of("java", "S1234"))
+ .withName("Test Rule")
+ .withLanguageKey("java")
+ .withSeverity(ActiveRule.Severity.MAJOR)
+ .build();
+
+ @TempDir
+ Path tempDir;
+
+ @TempDir
+ Path sourceDir;
+
+ private Path createSourceFile(String name, String content) throws IOException {
+ Path file = sourceDir.resolve(name);
+ Files.writeString(file, content);
+ return file;
+ }
+
+ @Test
+ void withProject_sets_project_context_on_server_context() throws Exception {
+ Path aJava = createSourceFile("A.java", "class A {}");
+ var project = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE))
+ .addModule("mod")
+ .withInputFiles(aJava)
+ .endModule();
+
+ SonarServerContext serverContext = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(project)
+ .buildServerContext();
+
+ assertThat(serverContext.projectContext()).isNotNull();
+ assertThat(serverContext.projectContext().activeRules()).containsExactly(RULE);
+ }
+
+ @Test
+ void buildScannerInput_includes_project_scanner_properties() throws Exception {
+ Path coreJava = createSourceFile("Core.java", "class Core {}");
+ Path appJava = createSourceFile("App.java", "class App {}");
+ var project = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE))
+ .addModule("core")
+ .withInputFiles(coreJava)
+ .withBinaries(Path.of("target/classes"))
+ .endModule()
+ .addModule("app")
+ .withInputFiles(appJava)
+ .withDependencies("core")
+ .endModule();
+
+ ScannerInput input = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(project)
+ .buildScannerInput();
+
+ assertThat(input.scannerProperties())
+ .containsEntry("sonar.modules", "core,app")
+ .containsEntry("core.sonar.sources", "src")
+ .containsEntry("core.sonar.java.binaries", "target/classes")
+ .containsEntry("app.sonar.sources", "src")
+ .containsEntry("app.sonar.java.libraries", "target/classes");
+ }
+
+ @Test
+ void buildScannerInput_uses_provided_base_dir() {
+ ScannerInput input = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .buildScannerInput();
+
+ assertThat(input.projectBaseDir()).isEqualTo(tempDir);
+ }
+
+ @Test
+ void buildScannerInput_empty_properties_when_no_project() {
+ ScannerInput input = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .buildScannerInput();
+
+ assertThat(input.scannerProperties()).isEmpty();
+ }
+
+ @Test
+ void buildServerContext_returns_valid_context_without_project() {
+ SonarServerContext serverContext = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .buildServerContext();
+
+ assertThat(serverContext.product()).isEqualTo(SonarServerContext.Product.SERVER);
+ assertThat(serverContext.projectContext()).isNull();
+ }
+
+ @Test
+ void createModuleDirectories_creates_src_subdirectory_for_each_module() {
+ var project = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE))
+ .addModule("moduleA")
+ .endModule()
+ .addModule("moduleB")
+ .endModule();
+
+ var analysis = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(project);
+
+ analysis.createModuleDirectories();
+
+ assertThat(tempDir.resolve("moduleA/src")).isDirectory();
+ assertThat(tempDir.resolve("moduleB/src")).isDirectory();
+ }
+
+ @Test
+ void createModuleDirectories_copies_input_files_into_src() throws Exception {
+ Path aJava = createSourceFile("A.java", "class A {}");
+ Path bJava = createSourceFile("B.java", "class B {}");
+
+ var project = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE))
+ .addModule("mod")
+ .withInputFiles(aJava, bJava)
+ .endModule();
+
+ var analysis = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(project);
+
+ analysis.createModuleDirectories();
+
+ assertThat(tempDir.resolve("mod/src/A.java")).isRegularFile().hasContent("class A {}");
+ assertThat(tempDir.resolve("mod/src/B.java")).isRegularFile().hasContent("class B {}");
+ }
+
+ @Test
+ void createModuleDirectories_is_idempotent() throws Exception {
+ Path aJava = createSourceFile("A.java", "class A {}");
+
+ var project = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE))
+ .addModule("mod")
+ .withInputFiles(aJava)
+ .endModule();
+
+ var analysis = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(project);
+
+ analysis.createModuleDirectories();
+ analysis.createModuleDirectories();
+
+ assertThat(tempDir.resolve("mod/src")).isDirectory();
+ assertThat(tempDir.resolve("mod/src/A.java")).isRegularFile();
+ }
+
+ @Test
+ void createModuleDirectories_no_op_when_no_modules() throws Exception {
+ var analysis = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir);
+
+ analysis.createModuleDirectories();
+
+ assertThat(Files.list(tempDir)).isEmpty();
+ }
+}
diff --git a/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilderTest.java b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilderTest.java
new file mode 100644
index 00000000000..b9a030f8397
--- /dev/null
+++ b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierProjectBuilderTest.java
@@ -0,0 +1,164 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.ActiveRule;
+import com.sonarsource.scanner.integrationtester.dsl.RuleKey;
+import com.sonarsource.scanner.integrationtester.dsl.SonarProjectContext;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+class ScannerVerifierProjectBuilderTest {
+
+ private static final ActiveRule RULE_1 = ActiveRule.builder()
+ .withKey(RuleKey.of("java", "S1234"))
+ .withName("Rule 1")
+ .withLanguageKey("java")
+ .withSeverity(ActiveRule.Severity.MAJOR)
+ .build();
+
+ private static final ActiveRule RULE_2 = ActiveRule.builder()
+ .withKey(RuleKey.of("java", "S5678"))
+ .withName("Rule 2")
+ .withLanguageKey("java")
+ .withSeverity(ActiveRule.Severity.INFO)
+ .build();
+
+ @Test
+ void build_produces_project_context_with_active_rules() {
+ SonarProjectContext context = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE_1, RULE_2))
+ .addModule("mod")
+ .withInputFiles(Path.of("src/A.java"))
+ .endModule()
+ .build();
+
+ assertThat(context.activeRules()).containsExactly(RULE_1, RULE_2);
+ }
+
+ @Test
+ void toScannerProperties_empty_modules_returns_empty_map() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE_1))
+ .toScannerProperties();
+
+ assertThat(props).isEmpty();
+ }
+
+ @Test
+ void toScannerProperties_single_module() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("moduleA")
+ .withInputFiles(Path.of("src/A.java"))
+ .withBinaries(Path.of("target/classes"))
+ .withLibraries(Path.of("lib/dep.jar"))
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsExactlyInAnyOrderEntriesOf(Map.of(
+ "sonar.modules", "moduleA",
+ "moduleA.sonar.sources", "src",
+ "moduleA.sonar.java.binaries", "target/classes",
+ "moduleA.sonar.java.libraries", "lib/dep.jar"));
+ }
+
+ @Test
+ void toScannerProperties_multi_module_with_dependencies() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("core")
+ .withInputFiles(Path.of("core/src/Core.java"))
+ .withBinaries(Path.of("core/target/classes"))
+ .endModule()
+ .addModule("api")
+ .withInputFiles(Path.of("api/src/Api.java"))
+ .withBinaries(Path.of("api/target/classes"))
+ .withDependencies("core")
+ .endModule()
+ .addModule("app")
+ .withInputFiles(Path.of("app/src/App.java"))
+ .withBinaries(Path.of("app/target/classes"))
+ .withDependencies("core", "api")
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props.get("sonar.modules")).isEqualTo("core,api,app");
+ assertThat(props.get("core.sonar.java.libraries")).isNull();
+ assertThat(props.get("api.sonar.java.libraries")).isEqualTo("core/target/classes");
+ assertThat(props.get("app.sonar.java.libraries")).isEqualTo("core/target/classes,api/target/classes");
+ }
+
+ @Test
+ void toScannerProperties_omits_empty_properties() {
+ Map props = ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .endModule()
+ .toScannerProperties();
+
+ assertThat(props).containsOnlyKeys("sonar.modules");
+ }
+
+ @Test
+ void addModule_rejects_null_name() {
+ ScannerVerifierProjectBuilder builder = ScannerVerifierProjectBuilder.newProject();
+
+ assertThatNullPointerException()
+ .isThrownBy(() -> builder.addModule(null))
+ .withMessage("Module name cannot be null");
+ }
+
+ @Test
+ void addModule_rejects_duplicate_name() {
+ ScannerVerifierProjectBuilder builder = ScannerVerifierProjectBuilder.newProject();
+ builder.addModule("mod").endModule();
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> builder.addModule("mod"))
+ .withMessage("Module 'mod' already exists");
+ }
+
+ @Test
+ void build_rejects_unknown_dependency() {
+ ScannerVerifierProjectBuilder builder = ScannerVerifierProjectBuilder.newProject()
+ .withActiveRules(List.of(RULE_1))
+ .addModule("app")
+ .withDependencies("nonexistent")
+ .endModule();
+
+ assertThatIllegalStateException()
+ .isThrownBy(builder::build)
+ .withMessage("Module 'app' depends on unknown module 'nonexistent'");
+ }
+
+ @Test
+ void toScannerProperties_rejects_unknown_dependency() {
+ ScannerVerifierProjectBuilder builder = ScannerVerifierProjectBuilder.newProject()
+ .addModule("app")
+ .withDependencies("nonexistent")
+ .endModule();
+
+ assertThatIllegalStateException()
+ .isThrownBy(builder::toScannerProperties)
+ .withMessage("Module 'app' depends on unknown module 'nonexistent'");
+ }
+}
diff --git a/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierTest.java b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierTest.java
new file mode 100644
index 00000000000..8769332b7fe
--- /dev/null
+++ b/java-checks-testkit/src/test/java/org/sonar/java/checks/verifier/sit/ScannerVerifierTest.java
@@ -0,0 +1,61 @@
+/*
+ * SonarQube Java
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.java.checks.verifier.sit;
+
+import com.sonarsource.scanner.integrationtester.dsl.issue.FileIssue;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ScannerVerifierTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldRunScannerEngineAnalysis() throws IOException {
+ Path sourceFilePath = Path.of("A.java");
+ Files.writeString(sourceFilePath, """
+ public class A {
+ private void BAD_METHOD_NAME() {}
+ }
+ """);
+
+ var analysis = ScannerVerifierAnalysisBuilder.newAnalysis(tempDir)
+ .withProject(
+ ScannerVerifierProjectBuilder.newProject()
+ .addModule("mod")
+ .withInputFiles(sourceFilePath)
+// .withBinaries(Path.of("target/classes"))
+// .withLibraries(Path.of("libs/lib.jar"))
+ .endModule()
+ .withActiveRule("S100")
+ );
+
+ var result = ScannerVerifier.execute(analysis);
+ assertThat(result.getAllIssues()).hasSize(1);
+ FileIssue actual = result.getAllIssues().get(0);
+ assertEquals("java:S100", actual.ruleKey());
+ }
+
+}
diff --git a/pom.xml b/pom.xml
index a1f63ba7e16..e53874b941b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -98,6 +98,7 @@
its/**,java-checks-test-sources/**
2.22.0.4796
6.1.0.3962
+ 1.1.0.1340
1.25.1.3886
-Xmx512m
sonar-java