From aaae03c153057334cafb059a8362a3ddf34c0358 Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Tue, 18 Aug 2026 16:43:30 +0200 Subject: [PATCH 1/5] SONARJAVA-6770: Integrate TestFileClassifier from analyzer-commons Delegate path/naming heuristics to TestFileClassifier from sonar-analyzer-commons and extend it with Java-specific signals (IT paths, filename suffixes/prefix). - Remove hasTestNamingConvention() and hasTestPathSegment(); replaced by TestFileClassifier.of() with JAVA_TEST_PATTERNS covering test/tests/testing directory segments, src/it/java, src/its/java, *Test/*Spec/*IT suffixes, and the new Test* prefix convention. - Cache the TestFileClassifier per Configuration via AtomicReference so WildcardPatterns are compiled once per analysis run, not once per file. - Keep isPlatformTestFile() and hasTestFrameworkAnnotation() unchanged; public API isTestFile(context) is backward-compatible. Co-Authored-By: Claude Sonnet 4.6 --- .../java/utils/JavaFileTypeClassifier.java | 111 ++++++----- .../utils/JavaFileTypeClassifierTest.java | 178 +++++++++--------- 2 files changed, 160 insertions(+), 129 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java index 47682ee7d63..3bab5fc76c8 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java @@ -17,14 +17,16 @@ package org.sonar.java.utils; import java.util.List; -import java.util.Locale; +import java.util.Map; import java.util.Set; -import java.util.regex.Pattern; +import java.util.concurrent.atomic.AtomicReference; import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.config.Configuration; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.semantic.SymbolMetadata; import org.sonar.plugins.java.api.tree.ClassTree; import org.sonar.plugins.java.api.tree.Tree; +import org.sonarsource.analyzer.commons.appsec.TestFileClassifier; /** * Enriches test scope determination beyond the platform's {@link InputFile.Type}. @@ -36,13 +38,18 @@ *

This classifier combines three signals: *

    *
  1. Platform truth: {@link InputFile#type()} from the Sonar scanner
  2. - *
  3. Naming conventions: file name patterns like {@code FooTest}, {@code FooIT}, {@code FooSpec}
  4. - *
  5. AST annotations: class-level test framework annotations ({@code @RunWith}, {@code @SpringBootTest}, etc.)
  6. + *
  7. Path and naming heuristics: delegated to {@link TestFileClassifier} from + * sonar-analyzer-commons, extended with Java-specific path conventions
  8. + *
  9. AST annotations: class-level test framework annotations ({@code @RunWith}, + * {@code @SpringBootTest}, etc.)
  10. *
* - *

A file is considered a test file if any signal indicates it — the platform type + *

A file is considered a test file if any signal indicates it. The platform type * takes priority when {@code TEST}, but a {@code MAIN}-typed file can be upgraded to test scope - * by the naming or annotation signals. + * by the path, naming, or annotation signals. + * + *

The path/naming heuristic is only applied when {@code sonar.tests} is not configured; if it + * is configured the platform already classifies test files as {@link InputFile.Type#TEST}. * *

Usage example in a check's {@code scanFile} method: *

{@code
@@ -81,19 +88,52 @@ public final class JavaFileTypeClassifier {
   );
 
   /**
-   * Path substrings that indicate a file lives in an integration-test source tree,
-   * following the Maven convention of {@code src/it/java} or {@code src/its/java}.
+   * {@link org.sonar.api.utils.WildcardPattern}-compatible path patterns for test file detection,
+   * passed to {@link TestFileClassifier#of(Configuration, String...)}.
+   *
+   * 

Covers: + *

    + *
  • Directory segments: {@code test}, {@code tests}, {@code testing}, {@code Test}, + * {@code Tests}, {@code __tests__}
  • + *
  • Maven integration-test source trees: {@code src/it/java}, {@code src/its/java}
  • + *
  • Filename suffixes: {@code Test}, {@code Tests}, {@code TestCase}, {@code IT}, + * {@code ITCase}, {@code Spec}, {@code Specs}
  • + *
  • Filename prefix: {@code Test}
  • + *
*/ - private static final List TEST_PATH_SUBPATHS = List.of("src/it/java", "src/its/java"); + private static final String[] JAVA_TEST_PATTERNS = { + // Directory segment patterns (superset of commons defaults + testing + Java-specific) + "**/Test/**", + "**/Tests/**", + "**/test/**", + "**/tests/**", + "**/testing/**", + "**/__tests__/**", + // Maven integration test source trees + "**/it/java/**", + "**/its/java/**", + // Filename suffix patterns + "**/*Test.java", + "**/*Tests.java", + "**/*TestCase.java", + "**/*IT.java", + "**/*ITCase.java", + "**/*Spec.java", + "**/*Specs.java", + // Filename prefix pattern + "**/Test*.java" + }; /** - * Matches file names (without {@code .java} extension) that follow standard test naming conventions - * by suffix: {@code Test}, {@code Tests}, {@code TestCase}, {@code IT}, {@code ITCase}, {@code Spec}, {@code Specs} - * (e.g. {@code FooTest}, {@code FooSpec}, {@code FooIT}). + * Cached {@link TestFileClassifier} for the most recently seen {@link Configuration}. + * In practice there is exactly one {@link Configuration} per analysis run, so a single + * cached entry avoids recompiling WildcardPatterns for every analyzed file. + * The {@link AtomicReference} ensures the config+classifier pair is always observed + * consistently. A race on simultaneous updates is benign: both threads produce an + * identical classifier for the same config. */ - private static final Pattern TEST_NAME_PATTERN = Pattern.compile( - "^[A-Z]\\w*(Test|Tests|TestCase|IT|ITCase|Spec|Specs)$" - ); + private static final AtomicReference> CLASSIFIER_REF = + new AtomicReference<>(); private JavaFileTypeClassifier() { // utility class @@ -101,14 +141,13 @@ private JavaFileTypeClassifier() { /** * Returns {@code true} if the file should be treated as test code. - * Combines all three signals (platform type, naming, AST annotations) with OR semantics. + * Combines all signals (platform type, path/naming heuristics, AST annotations) with OR semantics. * * @param context the current file scanner context */ public static boolean isTestFile(JavaFileScannerContext context) { return isPlatformTestFile(context) - || hasTestNamingConvention(context) - || hasTestPathSegment(context) + || getClassifier(context.getConfiguration()).looksLikeTestFile(context.getInputFile()) || hasTestFrameworkAnnotation(context); } @@ -122,32 +161,6 @@ static boolean isPlatformTestFile(JavaFileScannerContext context) { return context.getInputFile().type() == InputFile.Type.TEST; } - /** - * Returns {@code true} if the file name (without {@code .java} extension) matches - * {@link #TEST_NAME_PATTERN}. - * - * @param context the current file scanner context - */ - static boolean hasTestNamingConvention(JavaFileScannerContext context) { - String filename = context.getInputFile().filename(); - String baseName = filename.endsWith(".java") ? filename.substring(0, filename.length() - 5) : filename; - return TEST_NAME_PATTERN.matcher(baseName).matches(); - } - - /** - * Returns {@code true} if the file's URI path contains a known integration-test source tree - * substring: {@code src/it/java} or {@code src/its/java}. - * - *

This covers the Maven convention of placing integration tests under - * {@code src/it/java} or {@code src/its/java}. - * - * @param context the current file scanner context - */ - static boolean hasTestPathSegment(JavaFileScannerContext context) { - String path = context.getInputFile().uri().getPath().toLowerCase(Locale.ROOT); - return TEST_PATH_SUBPATHS.stream().anyMatch(path::contains); - } - /** * Returns {@code true} if any top-level class in the compilation unit carries a recognized * test framework annotation at the class level. @@ -176,4 +189,14 @@ private static boolean hasTestAnnotation(ClassTree classTree) { .map(ann -> ann.symbol().type().fullyQualifiedName()) .anyMatch(fqn -> TEST_ANNOTATION_PACKAGE_PREFIXES.stream().anyMatch(fqn::startsWith)); } + + private static TestFileClassifier getClassifier(Configuration config) { + var entry = CLASSIFIER_REF.get(); + if (entry == null || entry.getKey() != config) { + var fresh = Map.entry(config, TestFileClassifier.of(config, JAVA_TEST_PATTERNS)); + CLASSIFIER_REF.compareAndSet(entry, fresh); + entry = CLASSIFIER_REF.get(); + } + return entry.getValue(); + } } diff --git a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java index eceaafbb704..6fcfac0d3f8 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java @@ -19,11 +19,13 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.config.Configuration; import org.sonar.java.TestUtils; import org.sonar.java.ast.JavaAstScanner; import org.sonar.java.test.classpath.TestClasspathUtils; import org.sonar.java.testing.VisitorsBridgeForTests; import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.scanner.plugin.api.impl.config.MapSettings; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -31,6 +33,12 @@ class JavaFileTypeClassifierTest { + /** + * A {@link Configuration} that does not declare {@code sonar.tests}, so the path/naming + * heuristic in {@link org.sonarsource.analyzer.commons.appsec.TestFileClassifier} is active. + */ + private static final Configuration NO_SONAR_TESTS_CONFIG = new MapSettings().asConfig(); + // ------------------------------------------------------------------------- // isPlatformTestFile // ------------------------------------------------------------------------- @@ -47,80 +55,6 @@ void isPlatformTestFile_returnsFalse_forMainType() { assertThat(JavaFileTypeClassifier.isPlatformTestFile(context)).isFalse(); } - // ------------------------------------------------------------------------- - // hasTestNamingConvention - // ------------------------------------------------------------------------- - - @Test - void hasTestNamingConvention_recognizesSuffixes() { - assertNamingConvention(true, "FooTest.java"); - assertNamingConvention(true, "FooTests.java"); - assertNamingConvention(true, "FooTestCase.java"); - assertNamingConvention(true, "FooIT.java"); - assertNamingConvention(true, "FooITCase.java"); - assertNamingConvention(true, "FooSpec.java"); - assertNamingConvention(true, "FooSpecs.java"); - } - - @Test - void hasTestNamingConvention_returnsFalse_forProductionNames() { - assertNamingConvention(false, "Foo.java"); - assertNamingConvention(false, "FooService.java"); - assertNamingConvention(false, "FooController.java"); - // Lower-case 'test' in the middle is not a match - assertNamingConvention(false, "MyTestedCode.java"); - // Prefix-only conventions are not recognized - assertNamingConvention(false, "TestFoo.java"); - assertNamingConvention(false, "ITFoo.java"); - } - - private void assertNamingConvention(boolean expected, String filename) { - JavaFileScannerContext context = contextWithInputFile(TestUtils.emptyInputFile(filename, InputFile.Type.MAIN)); - assertThat(JavaFileTypeClassifier.hasTestNamingConvention(context)) - .as("Expected hasTestNamingConvention=%s for '%s'", expected, filename) - .isEqualTo(expected); - } - - // ------------------------------------------------------------------------- - // hasTestPathSegment - // ------------------------------------------------------------------------- - - @Test - void hasTestPathSegment_returnsTrue_forItSegment() { - assertPathSegment(true, "src/it/java/Foo.java"); - } - - @Test - void hasTestPathSegment_returnsTrue_forItsSegment() { - assertPathSegment(true, "src/its/java/Foo.java"); - } - - @Test - void hasTestPathSegment_isCaseInsensitive() { - assertPathSegment(true, "src/IT/java/Foo.java"); - assertPathSegment(true, "src/ITS/java/Foo.java"); - } - - @Test - void hasTestPathSegment_returnsFalse_forMainPath() { - assertPathSegment(false, "src/main/java/Foo.java"); - assertPathSegment(false, "src/test/java/Foo.java"); - } - - @Test - void hasTestPathSegment_returnsFalse_whenSegmentIsSubstring() { - // "itself" or "iteration" should not match — only exact segment - assertPathSegment(false, "src/itself/java/Foo.java"); - assertPathSegment(false, "src/iteration/java/Foo.java"); - } - - private void assertPathSegment(boolean expected, String relativePath) { - JavaFileScannerContext context = contextWithInputFile(TestUtils.emptyInputFile(relativePath, InputFile.Type.MAIN)); - assertThat(JavaFileTypeClassifier.hasTestPathSegment(context)) - .as("Expected hasTestPathSegment=%s for '%s'", expected, relativePath) - .isEqualTo(expected); - } - // ------------------------------------------------------------------------- // hasTestFrameworkAnnotation // ------------------------------------------------------------------------- @@ -171,43 +105,117 @@ private void assertAnnotationSignal(boolean expected, String filePath) { } // ------------------------------------------------------------------------- - // isTestFile (combined signal) + // isTestFile — platform signal // ------------------------------------------------------------------------- @Test void isTestFile_returnsTrue_whenPlatformSaysTest() { - JavaFileScannerContext context = contextWithInputFile(TestUtils.emptyInputFile("Foo.java", InputFile.Type.TEST)); + JavaFileScannerContext context = contextWithInputFileAndConfig( + TestUtils.emptyInputFile("Foo.java", InputFile.Type.TEST), NO_SONAR_TESTS_CONFIG); assertThat(JavaFileTypeClassifier.isTestFile(context)).isTrue(); } + // ------------------------------------------------------------------------- + // isTestFile — filename suffix conventions + // ------------------------------------------------------------------------- + @Test - void isTestFile_returnsTrue_whenPathSegmentMatches_evenIfPlatformSaysMain() { - JavaFileScannerContext context = contextWithInputFile(TestUtils.emptyInputFile("src/it/java/Foo.java", InputFile.Type.MAIN)); - assertThat(JavaFileTypeClassifier.isTestFile(context)).isTrue(); + void isTestFile_recognizes_testFileSuffixes() { + assertIsTestFile(true, "FooTest.java"); + assertIsTestFile(true, "FooTests.java"); + assertIsTestFile(true, "FooTestCase.java"); + assertIsTestFile(true, "FooIT.java"); + assertIsTestFile(true, "FooITCase.java"); + assertIsTestFile(true, "FooSpec.java"); + assertIsTestFile(true, "FooSpecs.java"); } @Test - void isTestFile_returnsTrue_whenNamingMatches_evenIfPlatformSaysMain() { - JavaFileScannerContext context = contextWithInputFile(TestUtils.emptyInputFile("FooTest.java", InputFile.Type.MAIN)); - // fileParsed() not set → defaults to false (Mockito default for boolean), no AST signal - assertThat(JavaFileTypeClassifier.isTestFile(context)).isTrue(); + void isTestFile_recognizes_TestPrefix() { + assertIsTestFile(true, "TestFoo.java"); + assertIsTestFile(true, "TestBar.java"); + } + + @Test + void isTestFile_returnsFalse_forProductionNames() { + assertIsTestFile(false, "Foo.java"); + assertIsTestFile(false, "FooService.java"); + assertIsTestFile(false, "FooController.java"); } + // ------------------------------------------------------------------------- + // isTestFile — path/directory segment conventions + // ------------------------------------------------------------------------- + + @Test + void isTestFile_recognizes_mavenTestPath() { + assertIsTestFile(true, "src/test/java/Foo.java"); + } + + @Test + void isTestFile_recognizes_mavenItPath() { + assertIsTestFile(true, "src/it/java/Foo.java"); + } + + @Test + void isTestFile_recognizes_mavenItsPath() { + assertIsTestFile(true, "src/its/java/Foo.java"); + } + + @Test + void isTestFile_recognizes_testDirectorySegment() { + assertIsTestFile(true, "src/test/Foo.java"); + assertIsTestFile(true, "modules/core/test/Foo.java"); + } + + @Test + void isTestFile_recognizes_testsDirectorySegment() { + assertIsTestFile(true, "src/tests/Foo.java"); + } + + @Test + void isTestFile_recognizes_testingDirectorySegment() { + assertIsTestFile(true, "src/testing/Foo.java"); + } + + @Test + void isTestFile_returnsFalse_forProductionPath() { + assertIsTestFile(false, "src/main/java/Foo.java"); + } + + // ------------------------------------------------------------------------- + // isTestFile — no signal + // ------------------------------------------------------------------------- + @Test void isTestFile_returnsFalse_whenNoSignal() { JavaFileScannerContext context = mock(JavaFileScannerContext.class); when(context.getInputFile()).thenReturn(TestUtils.emptyInputFile("Foo.java", InputFile.Type.MAIN)); + when(context.getConfiguration()).thenReturn(NO_SONAR_TESTS_CONFIG); when(context.fileParsed()).thenReturn(false); assertThat(JavaFileTypeClassifier.isTestFile(context)).isFalse(); } // ------------------------------------------------------------------------- - // Helper + // Helpers // ------------------------------------------------------------------------- + private static void assertIsTestFile(boolean expected, String filename) { + JavaFileScannerContext context = contextWithInputFileAndConfig( + TestUtils.emptyInputFile(filename, InputFile.Type.MAIN), NO_SONAR_TESTS_CONFIG); + assertThat(JavaFileTypeClassifier.isTestFile(context)) + .as("Expected isTestFile=%s for '%s'", expected, filename) + .isEqualTo(expected); + } + private static JavaFileScannerContext contextWithInputFile(InputFile inputFile) { + return contextWithInputFileAndConfig(inputFile, NO_SONAR_TESTS_CONFIG); + } + + private static JavaFileScannerContext contextWithInputFileAndConfig(InputFile inputFile, Configuration config) { JavaFileScannerContext context = mock(JavaFileScannerContext.class); when(context.getInputFile()).thenReturn(inputFile); + when(context.getConfiguration()).thenReturn(config); return context; } -} +} \ No newline at end of file From 9d1fb5a51eb59dcc7e7b8baeb3d2095bbb3805ad Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Wed, 19 Aug 2026 10:12:40 +0200 Subject: [PATCH 2/5] Remove Test* filename prefix pattern from JavaFileTypeClassifier The **/Test*.java pattern is too broad and generates too many false positives (e.g. TestUtils, TestHelper production utilities). Suffix-based patterns (*Test, *Tests, *TestCase, *IT, *ITCase, *Spec, *Specs) provide sufficient recall without the noise. Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/sonar/java/utils/JavaFileTypeClassifier.java | 5 +---- .../org/sonar/java/utils/JavaFileTypeClassifierTest.java | 6 ------ 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java index 3bab5fc76c8..a53f51825c0 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java @@ -98,7 +98,6 @@ public final class JavaFileTypeClassifier { *

  • Maven integration-test source trees: {@code src/it/java}, {@code src/its/java}
  • *
  • Filename suffixes: {@code Test}, {@code Tests}, {@code TestCase}, {@code IT}, * {@code ITCase}, {@code Spec}, {@code Specs}
  • - *
  • Filename prefix: {@code Test}
  • * */ private static final String[] JAVA_TEST_PATTERNS = { @@ -119,9 +118,7 @@ public final class JavaFileTypeClassifier { "**/*IT.java", "**/*ITCase.java", "**/*Spec.java", - "**/*Specs.java", - // Filename prefix pattern - "**/Test*.java" + "**/*Specs.java" }; /** diff --git a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java index 6fcfac0d3f8..a1d68b0b1f7 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java @@ -130,12 +130,6 @@ void isTestFile_recognizes_testFileSuffixes() { assertIsTestFile(true, "FooSpecs.java"); } - @Test - void isTestFile_recognizes_TestPrefix() { - assertIsTestFile(true, "TestFoo.java"); - assertIsTestFile(true, "TestBar.java"); - } - @Test void isTestFile_returnsFalse_forProductionNames() { assertIsTestFile(false, "Foo.java"); From 0fb4946447590d2727f9f9e763de1edd61e282fb Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Wed, 19 Aug 2026 10:28:33 +0200 Subject: [PATCH 3/5] Test sonar.tests config: naming/path heuristic disabled, other signals unaffected When sonar.tests is configured, TestFileClassifier suppresses its path/naming heuristic because the platform already classifies test files as TEST. Add three tests to lock in this boundary: - naming and path signals are ignored when sonar.tests is set - InputFile.Type.TEST remains authoritative regardless - annotation signal (hasTestFrameworkAnnotation) is config-independent Co-Authored-By: Claude Sonnet 4.6 --- .../utils/JavaFileTypeClassifierTest.java | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java index a1d68b0b1f7..cde1f8df737 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java @@ -177,6 +177,39 @@ void isTestFile_returnsFalse_forProductionPath() { assertIsTestFile(false, "src/main/java/Foo.java"); } + // ------------------------------------------------------------------------- + // isTestFile — sonar.tests configured: path/naming heuristic disabled + // ------------------------------------------------------------------------- + + @Test + void isTestFile_withSonarTestsConfigured_ignoresNamingAndPathSignals() { + // When sonar.tests is set, TestFileClassifier suppresses the heuristic because + // the platform already classifies test files as InputFile.Type.TEST. + // Names and paths that would otherwise trigger the heuristic must return false. + var config = new MapSettings().setProperty("sonar.tests", "src/test/java").asConfig(); + + assertIsTestFileWithConfig(false, "FooTest.java", config); + assertIsTestFileWithConfig(false, "src/test/java/Foo.java", config); + assertIsTestFileWithConfig(false, "src/it/java/Foo.java", config); + } + + @Test + void isTestFile_withSonarTestsConfigured_platformTypeStillApplies() { + var config = new MapSettings().setProperty("sonar.tests", "src/test/java").asConfig(); + + // InputFile.Type.TEST is checked before the heuristic and is always authoritative. + var context = contextWithInputFileAndConfig( + TestUtils.emptyInputFile("Foo.java", InputFile.Type.TEST), config); + assertThat(JavaFileTypeClassifier.isTestFile(context)).isTrue(); + } + + @Test + void isTestFile_withSonarTestsConfigured_annotationSignalStillApplies() { + // hasTestFrameworkAnnotation() reads the AST, not the configuration — + // it must keep working regardless of whether sonar.tests is set. + assertAnnotationSignal(true, "src/test/files/utils/SampleWithRunWith.java"); + } + // ------------------------------------------------------------------------- // isTestFile — no signal // ------------------------------------------------------------------------- @@ -194,6 +227,14 @@ void isTestFile_returnsFalse_whenNoSignal() { // Helpers // ------------------------------------------------------------------------- + private static void assertIsTestFileWithConfig(boolean expected, String filename, Configuration config) { + JavaFileScannerContext context = contextWithInputFileAndConfig( + TestUtils.emptyInputFile(filename, InputFile.Type.MAIN), config); + assertThat(JavaFileTypeClassifier.isTestFile(context)) + .as("Expected isTestFile=%s for '%s' with sonar.tests config", expected, filename) + .isEqualTo(expected); + } + private static void assertIsTestFile(boolean expected, String filename) { JavaFileScannerContext context = contextWithInputFileAndConfig( TestUtils.emptyInputFile(filename, InputFile.Type.MAIN), NO_SONAR_TESTS_CONFIG); @@ -212,4 +253,4 @@ private static JavaFileScannerContext contextWithInputFileAndConfig(InputFile in when(context.getConfiguration()).thenReturn(config); return context; } -} \ No newline at end of file +} From f94524e29fbf45e1ac1d496b6f8ee3d19cc2f63c Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Wed, 19 Aug 2026 11:31:30 +0200 Subject: [PATCH 4/5] Add uppercase IT/ITS Maven integration-test path patterns Add **/IT/java/** and **/ITS/java/** to JAVA_TEST_PATTERNS to cover projects that use uppercase directory names for integration tests. Update Javadoc and consolidate the four separate IT/ITS path tests into one. Co-Authored-By: Claude Sonnet 4.6 --- .../java/utils/JavaFileTypeClassifier.java | 5 ++++- .../utils/JavaFileTypeClassifierTest.java | 20 ++++++------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java index a53f51825c0..608c397b0c3 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/JavaFileTypeClassifier.java @@ -95,7 +95,8 @@ public final class JavaFileTypeClassifier { *
      *
    • Directory segments: {@code test}, {@code tests}, {@code testing}, {@code Test}, * {@code Tests}, {@code __tests__}
    • - *
    • Maven integration-test source trees: {@code src/it/java}, {@code src/its/java}
    • + *
    • Maven integration-test source trees: {@code src/it/java}, {@code src/its/java}, + * {@code src/IT/java}, {@code src/ITS/java}
    • *
    • Filename suffixes: {@code Test}, {@code Tests}, {@code TestCase}, {@code IT}, * {@code ITCase}, {@code Spec}, {@code Specs}
    • *
    @@ -111,6 +112,8 @@ public final class JavaFileTypeClassifier { // Maven integration test source trees "**/it/java/**", "**/its/java/**", + "**/IT/java/**", + "**/ITS/java/**", // Filename suffix patterns "**/*Test.java", "**/*Tests.java", diff --git a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java index cde1f8df737..844897e0bb3 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/JavaFileTypeClassifierTest.java @@ -135,6 +135,8 @@ void isTestFile_returnsFalse_forProductionNames() { assertIsTestFile(false, "Foo.java"); assertIsTestFile(false, "FooService.java"); assertIsTestFile(false, "FooController.java"); + assertIsTestFile(false, "TestUtils.java"); + assertIsTestFile(false, "TestHelper.java"); } // ------------------------------------------------------------------------- @@ -147,28 +149,18 @@ void isTestFile_recognizes_mavenTestPath() { } @Test - void isTestFile_recognizes_mavenItPath() { + void isTestFile_recognizes_mavenITPaths() { assertIsTestFile(true, "src/it/java/Foo.java"); - } - - @Test - void isTestFile_recognizes_mavenItsPath() { assertIsTestFile(true, "src/its/java/Foo.java"); + assertIsTestFile(true, "src/IT/java/Foo.java"); + assertIsTestFile(true, "src/ITS/java/Foo.java"); } @Test - void isTestFile_recognizes_testDirectorySegment() { + void isTestFile_recognizes_testDirectorySegments() { assertIsTestFile(true, "src/test/Foo.java"); assertIsTestFile(true, "modules/core/test/Foo.java"); - } - - @Test - void isTestFile_recognizes_testsDirectorySegment() { assertIsTestFile(true, "src/tests/Foo.java"); - } - - @Test - void isTestFile_recognizes_testingDirectorySegment() { assertIsTestFile(true, "src/testing/Foo.java"); } From 43a6fa9a47dfe73c0413c526aa664bd7df153992 Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Fri, 21 Aug 2026 16:51:50 +0200 Subject: [PATCH 5/5] Treat Bean Validation @NotNull as WEAK_NULLABLE to fix FPs (JAVASE-241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit javax.validation.constraints.NotNull and jakarta.validation.constraints.NotNull are runtime constraints, not static nullability guarantees. Their previous NON_NULL classification also caused an inconsistency (SONARJAVA-3803): @NotNull without arguments resolved to NON_NULL while @NotNull(groups=...) resolved to UNKNOWN. Moving both to WEAK_NULLABLE gives consistent, conservative treatment. Rules fixed as a direct consequence: - S4454: @NotNull on equals() parameter no longer fires (WEAK_NULLABLE is not isNonNull()) - S6539: @NotNull inside @NullMarked no longer flagged as redundant Rules requiring explicit guards after reclassification: - S4682: added FQN-based exclusion — BV @NotNull on a primitive return type is a validation constraint, not a nullable annotation - S2638: added isBeanValidationAnnotation() guard in compareNullability() — when the upper-bound annotation is a BV annotation, the comparison is skipped so that BV @NotNull on a parent param or child return does not incorrectly fire Co-Authored-By: Claude Sonnet 4.6 --- .../no_default/NullabilityAtMethodLevel.java | 13 ++++++ .../NullabilityAtVariableLevel.java | 4 +- ...alsParametersMarkedNonNullCheckSample.java | 5 +- .../PrimitivesMarkedNullableCheckSample.java | 7 +++ .../ChangeMethodContractCheck.java | 46 +++++++++++++++---- ...dantNullabilityAnnotationsCheckSample.java | 2 +- .../checks/ChangeMethodContractCheck.java | 25 ++++++++-- .../checks/PrimitivesMarkedNullableCheck.java | 13 +++++- .../JSymbolMetadataNullabilityHelper.java | 7 ++- 9 files changed, 101 insertions(+), 21 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtMethodLevel.java b/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtMethodLevel.java index 4428585b9b3..1c3ee0a0a40 100644 --- a/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtMethodLevel.java +++ b/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtMethodLevel.java @@ -71,6 +71,19 @@ public Object id2019_type_NO_ANNOTATION_level_PACKAGE( return new Object(); } + // ============== Bean Validation @NotNull is treated as WEAK_NULLABLE, not NON_NULL ============== + @javax.validation.constraints.NotNull + public Object id2025_type_WEAK_NULLABLE_level_METHOD( + @javax.validation.constraints.NotNull Object id2026_type_WEAK_NULLABLE_level_VARIABLE) { + return new Object(); + } + + @jakarta.validation.constraints.NotNull + public Object id2027_type_WEAK_NULLABLE_level_METHOD( + @jakarta.validation.constraints.NotNull Object id2028_type_WEAK_NULLABLE_level_VARIABLE) { + return new Object(); + } + } abstract class NullabilityAtMethodLevelParent { diff --git a/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtVariableLevel.java b/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtVariableLevel.java index 044d5dd6103..ce3906b4910 100644 --- a/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtVariableLevel.java +++ b/java-checks-test-sources/default/src/main/java/annotations/nullability/no_default/NullabilityAtVariableLevel.java @@ -105,7 +105,9 @@ public class NullabilityAtVariableLevel { @javax.annotation.Nonnull Object id1032_type_NON_NULL_level_VARIABLE; @javax.validation.constraints.NotNull - Object id1033_type_NON_NULL_level_VARIABLE; + Object id1033_type_WEAK_NULLABLE_level_VARIABLE; + @jakarta.validation.constraints.NotNull + Object id1090_type_WEAK_NULLABLE_level_VARIABLE; @lombok.NonNull Object id1034_type_NON_NULL_level_VARIABLE; @org.checkerframework.checker.nullness.compatqual.NonNullDecl diff --git a/java-checks-test-sources/default/src/main/java/checks/EqualsParametersMarkedNonNullCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/EqualsParametersMarkedNonNullCheckSample.java index 456fe6c7403..a79d66fc95b 100644 --- a/java-checks-test-sources/default/src/main/java/checks/EqualsParametersMarkedNonNullCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/EqualsParametersMarkedNonNullCheckSample.java @@ -43,11 +43,8 @@ public boolean equals(@Nonnull C c) { // Compliant static class F { public boolean equals( - @javax.validation.constraints.NotNull // Noncompliant {{"equals" method parameters should not be marked "@NotNull".}} [[quickfixes=qf2]] -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + @javax.validation.constraints.NotNull // Compliant: exceptional annotation java.lang.Object object) { - // fix@qf2 {{Remove "@NotNull"}} - // edit@qf2 [[sc=7;ec=7;el=+2]] {{}} return false; } } diff --git a/java-checks-test-sources/default/src/main/java/checks/PrimitivesMarkedNullableCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/PrimitivesMarkedNullableCheckSample.java index 1e362c319d9..0dacf98898c 100644 --- a/java-checks-test-sources/default/src/main/java/checks/PrimitivesMarkedNullableCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/PrimitivesMarkedNullableCheckSample.java @@ -1,5 +1,6 @@ package checks; +import jakarta.validation.constraints.NotNull; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -51,6 +52,12 @@ abstract class PrimitivesMarkedNullableCheckSample { @Nonnull public double getDouble2_2() { return 0.0; } // Compliant, Nonnull is useless, but is accepted as it can be added for consistency + @javax.validation.constraints.NotNull + public int getIntWithJavaxNotNull() { return 0; } // Compliant, Bean Validation @NotNull is a runtime constraint, not a nullable annotation + + @NotNull + public int getIntWithJakartaNotNull() { return 0; } // Compliant, Bean Validation @NotNull is a runtime constraint, not a nullable annotation + @javax.annotation.Nullable public Double getDouble3() { return 0.0; } diff --git a/java-checks-test-sources/default/src/main/java/checks/S2638_ChangeMethodContractCheck/noPackageInfo/ChangeMethodContractCheck.java b/java-checks-test-sources/default/src/main/java/checks/S2638_ChangeMethodContractCheck/noPackageInfo/ChangeMethodContractCheck.java index 3b74c31176b..a4c981c3e36 100644 --- a/java-checks-test-sources/default/src/main/java/checks/S2638_ChangeMethodContractCheck/noPackageInfo/ChangeMethodContractCheck.java +++ b/java-checks-test-sources/default/src/main/java/checks/S2638_ChangeMethodContractCheck/noPackageInfo/ChangeMethodContractCheck.java @@ -187,35 +187,63 @@ void argAnnotatedDirectlyNullable(@MyNonnullMetaAnnotation Object a) { } // Nonc } /** - * Not null with arguments is inconsistently supported. See SONARJAVA-3803. + * Javax and Jakarta validation NotNull annotations are treated as weakly nullable. */ -class ChangeMethodContractCheck_NonnullWithArguments { +class ChangeMethodContractCheck_JavaxAndJakartaValidation { class Parent { @javax.validation.constraints.NotNull(groups = { ChangeMethodContractCheck.class }) - String annotatedNotNullWithArg(Object a) { return "null"; } + String annotatedJavaxNotNullWithArg(Object a) { return "null"; } @javax.validation.constraints.NotNull - String annotatedNotNullWithoutArg(Object a) { return "null"; } + String annotatedJavaxNotNullWithoutArg(Object a) { return "null"; } + + @jakarta.validation.constraints.NotNull(groups = { ChangeMethodContractCheck.class }) + String annotatedJakartaNotNullWithArg(Object a) { return "null"; } + + @jakarta.validation.constraints.NotNull + String annotatedJakartaNotNullWithoutArg(Object a) { return "null"; } void argAnnotatedNoNullWithArg(@javax.validation.constraints.NotNull(groups = { ChangeMethodContractCheck.class }) Object a) { } void argAnnotatedNoNullWithoutArg(@javax.validation.constraints.NotNull Object a) { } + void argAnnotatedJavaxNotNull(@javax.validation.constraints.NotNull Object a) { } + void argAnnotatedJakartaNotNull(@jakarta.validation.constraints.NotNull Object a) { } + + @javax.validation.constraints.NotNull + String methodNonnullJavaxBvReturn(Object a) { return ""; } + @jakarta.validation.constraints.NotNull + String methodNonnullJakartaBvReturn(Object a) { return ""; } } class Child extends Parent { - // Parent is not strictly not null (NotNull with arguments). + // Parent is weakly nullable. + @Override + @javax.annotation.CheckForNull + String annotatedJavaxNotNullWithArg(Object a) { return null; } + + @Override + @javax.annotation.CheckForNull + String annotatedJavaxNotNullWithoutArg(Object a) { return null; } + @Override @javax.annotation.CheckForNull - String annotatedNotNullWithArg(Object a) { return null; } + String annotatedJakartaNotNullWithArg(Object a) { return null; } @Override - // This one is a TP though. @javax.annotation.CheckForNull - String annotatedNotNullWithoutArg(Object a) { return null; } // Noncompliant {{Fix the incompatibility of the annotation @CheckForNull to honor @NotNull of the overridden method.}} + String annotatedJakartaNotNullWithoutArg(Object a) { return null; } - // It works correctly for arguments though. + // It works correctly also for arguments. void argAnnotatedNoNullWithArg(@javax.annotation.CheckForNull Object a) { } void argAnnotatedNoNullWithoutArg(@javax.annotation.CheckForNull Object a) { } + // Bean Validation @NotNull is a runtime constraint: strengthening to @Nonnull in child is not a contract violation. + void argAnnotatedJavaxNotNull(@javax.annotation.Nonnull Object a) { } // Compliant + void argAnnotatedJakartaNotNull(@javax.annotation.Nonnull Object a) { } // Compliant + // It works correctly also for return values: BV @NotNull to @Nonnull is strengthening. + @javax.annotation.Nonnull + String methodNonnullJavaxBvReturn(Object a) { return ""; } // Compliant + @javax.annotation.Nonnull + String methodNonnullJakartaBvReturn(Object a) { return ""; } // Compliant } } diff --git a/java-checks-test-sources/default/src/main/java/checks/jspecify/RedundantNullabilityAnnotationsCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/jspecify/RedundantNullabilityAnnotationsCheckSample.java index 0bf545a82cb..a9b7674f1d7 100644 --- a/java-checks-test-sources/default/src/main/java/checks/jspecify/RedundantNullabilityAnnotationsCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/jspecify/RedundantNullabilityAnnotationsCheckSample.java @@ -20,7 +20,7 @@ public void methodNonNullParam(@javax.annotation.Nonnull(when= When.ALWAYS) Obje // ... } - @NotNull // Noncompliant {{Remove redundant annotation @NotNull as inside scope annotation @NullMarked at class level.}} + @NotNull // Compliant public Integer methodJXNonNullReturn(Object o) { return 0; } diff --git a/java-checks/src/main/java/org/sonar/java/checks/ChangeMethodContractCheck.java b/java-checks/src/main/java/org/sonar/java/checks/ChangeMethodContractCheck.java index 8f5d328cd6a..83749dae56a 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/ChangeMethodContractCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/ChangeMethodContractCheck.java @@ -20,6 +20,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import org.sonar.check.Rule; import org.sonar.java.checks.helpers.MethodTreeUtils; import org.sonar.java.model.JUtils; @@ -34,12 +35,21 @@ import org.sonar.plugins.java.api.tree.TypeTree; import org.sonar.plugins.java.api.tree.VariableTree; +import org.sonarsource.analyzer.commons.collections.SetUtils; + import static org.sonar.java.checks.helpers.NullabilityDataUtils.nullabilityAsString; import static org.sonar.plugins.java.api.semantic.SymbolMetadata.NullabilityLevel.PACKAGE; @Rule(key = "S2638") public class ChangeMethodContractCheck extends IssuableSubscriptionVisitor { + // Bean Validation @NotNull is a runtime constraint, not a static nullability guarantee. + // When the parent parameter is annotated with it, strengthening it to @Nonnull in the child is not a contract violation. + private static final Set BEAN_VALIDATION_ANNOTATIONS = SetUtils.immutableSetOf( + "javax.validation.constraints.NotNull", + "jakarta.validation.constraints.NotNull" + ); + @Override public List nodesToVisit() { return Collections.singletonList(Tree.Kind.METHOD); @@ -83,9 +93,12 @@ private void checkContractChange(MethodTree methodTree, Symbol.MethodSymbol over private void compareNullability(TypeTree tree, SymbolMetadata upperBound, SymbolMetadata lowerBound, boolean overriddenIsLowerBound) { // Check current level - if (upperBound.nullabilityData().isNullable(PACKAGE, false, false) - && lowerBound.nullabilityData().isNonNull(PACKAGE, false, false)) { - reportIssue(tree, lowerBound.nullabilityData(), upperBound.nullabilityData(), overriddenIsLowerBound); + NullabilityData upperData = upperBound.nullabilityData(); + NullabilityData lowerData = lowerBound.nullabilityData(); + if (!isBeanValidationAnnotation(upperData) + && upperData.isNullable(PACKAGE, false, false) + && lowerData.isNonNull(PACKAGE, false, false)) { + reportIssue(tree, lowerData, upperData, overriddenIsLowerBound); } // Check type parameters @@ -114,6 +127,12 @@ private void checkParameter(VariableTree parameter, SymbolMetadata overrideePara compareNullability(parameter.type(), overrideeParam, parameter.symbol().metadata(), false); } + private static boolean isBeanValidationAnnotation(NullabilityData data) { + SymbolMetadata.AnnotationInstance annotation = data.annotation(); + return annotation != null + && BEAN_VALIDATION_ANNOTATIONS.contains(annotation.symbol().type().fullyQualifiedName()); + } + private void reportIssue(Tree reportLocation, NullabilityData upperBound, NullabilityData lowerBound, boolean overriddenIsLowerBound) { NullabilityData otherNullability = overriddenIsLowerBound ? lowerBound : upperBound; NullabilityData overrideeNullability = overriddenIsLowerBound ? upperBound : lowerBound; diff --git a/java-checks/src/main/java/org/sonar/java/checks/PrimitivesMarkedNullableCheck.java b/java-checks/src/main/java/org/sonar/java/checks/PrimitivesMarkedNullableCheck.java index 651ecc6d6e1..e651e4a8778 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/PrimitivesMarkedNullableCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/PrimitivesMarkedNullableCheck.java @@ -18,6 +18,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; import org.sonar.check.Rule; import org.sonar.java.checks.helpers.QuickFixHelper; import org.sonar.java.reporting.JavaQuickFix; @@ -28,6 +29,7 @@ import org.sonar.plugins.java.api.tree.MethodTree; import org.sonar.plugins.java.api.tree.Tree; import org.sonar.plugins.java.api.tree.TypeTree; +import org.sonarsource.analyzer.commons.collections.SetUtils; import static org.sonar.java.reporting.AnalyzerMessage.textSpanBetween; import static org.sonar.plugins.java.api.semantic.SymbolMetadata.NullabilityLevel.METHOD; @@ -35,6 +37,14 @@ @Rule(key = "S4682") public final class PrimitivesMarkedNullableCheck extends IssuableSubscriptionVisitor { + // Bean Validation @NotNull is a runtime constraint, not a nullability annotation. + // Primitives can never be null, so this constraint is meaningless on a primitive return type, + // but it is a different concern from what this rule targets (nullable annotations on primitives). + private static final Set CONSTRAINT_ANNOTATIONS_NOT_FLAGGED = SetUtils.immutableSetOf( + "javax.validation.constraints.NotNull", + "jakarta.validation.constraints.NotNull" + ); + @Override public List nodesToVisit() { return Collections.singletonList(Tree.Kind.METHOD); @@ -50,7 +60,8 @@ public void visitNode(Tree tree) { SymbolMetadata.AnnotationInstance annotation = nullabilityData.annotation(); Tree annotationTree = nullabilityData.declaration(); // Both "annotation" and "declaration" should never be null, as we only target directly annotated methods. We keep the check for defensive programming. - if (annotation != null && annotationTree != null) { + if (annotation != null && annotationTree != null + && !CONSTRAINT_ANNOTATIONS_NOT_FLAGGED.contains(annotation.symbol().type().fullyQualifiedName())) { String annotationName = annotation.symbol().name(); QuickFixHelper.newIssue(context) .forRule(this) diff --git a/java-frontend/src/main/java/org/sonar/java/model/JSymbolMetadataNullabilityHelper.java b/java-frontend/src/main/java/org/sonar/java/model/JSymbolMetadataNullabilityHelper.java index 505fe318d9e..cf5390f7da4 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/JSymbolMetadataNullabilityHelper.java +++ b/java-frontend/src/main/java/org/sonar/java/model/JSymbolMetadataNullabilityHelper.java @@ -91,6 +91,11 @@ private JSymbolMetadataNullabilityHelper() { "io.reactivex.rxjava3.annotations.Nullable", "javax.annotation.Nullable", "jakarta.annotation.Nullable", + // Bean Validation @NotNull is a runtime constraint, not a static nullability guarantee. + // It is placed here rather than NONNULL_ANNOTATIONS because it cannot serve as a reliable + // static analysis signal (especially when groups= is used), so it is treated conservatively. + "javax.validation.constraints.NotNull", + "jakarta.validation.constraints.NotNull", "org.checkerframework.checker.nullness.compatqual.NullableDecl", "org.checkerframework.checker.nullness.compatqual.NullableType", "org.checkerframework.checker.nullness.qual.Nullable", @@ -119,8 +124,6 @@ private JSymbolMetadataNullabilityHelper() { "edu.umd.cs.findbugs.annotations.NonNull", "io.reactivex.annotations.NonNull", "io.reactivex.rxjava3.annotations.NonNull", - "javax.validation.constraints.NotNull", - "jakarta.validation.constraints.NotNull", "lombok.NonNull", "org.checkerframework.checker.nullness.compatqual.NonNullDecl", "org.checkerframework.checker.nullness.compatqual.NonNullType",