Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cli/src/main/java/dev/sonarcli/cli/SonarCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ public final class SonarCommand implements Runnable {
description = "A SonarQube quality-profile XML to drive rule selection.")
private String configProfile;

@Option(names = "--test-path", paramLabel = "GLOB",
description = {
"Treat files matching GLOB as test code (additive).",
"Repeatable. Augments the built-in test-path detection",
"(src/test/**, *Test.java, *_test.go, *.spec.ts, ...).",
"Useful when the project's test layout is non-standard,",
"e.g. --test-path 'src/integration/**'."})
private java.util.List<String> additionalTestPaths = new java.util.ArrayList<>();

@Spec
private CommandSpec spec;

Expand Down Expand Up @@ -148,7 +157,10 @@ private int analyzeAndReport(FileResolver.ResolvedFiles resolved, PrintWriter ou
AnalyzeRequest request = new AnalyzeRequest(
resolved.baseDir().toString(),
resolved.relativePaths(),
List.of(), resolveProfileRef(), List.of());
List.of(), resolveProfileRef(), List.of(),
additionalTestPaths != null
? List.copyOf(additionalTestPaths)
: List.of());
AnalyzeResponse response = rpc.analyze(request);

List<Issue> filtered = response.issues().stream()
Expand Down
29 changes: 28 additions & 1 deletion daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ public AnalyzeResponse analyze(AnalyzeRequest request) {
}
}

/**
* Glob-match a relative path against any of the patterns the caller passed
* via {@link AnalyzeRequest#additionalTestPaths()}. Uses Java's standard
* {@code glob:} {@link java.nio.file.PathMatcher} so callers can pass
* shapes like {@code src/integration/**} or {@code **&#47;legacy/*Test.java}.
*/
private static boolean matchesAnyGlob(String relativePath, List<String> globs) {
if (globs == null || globs.isEmpty()) {
return false;
}
java.nio.file.Path p = java.nio.file.Path.of(relativePath);
for (String glob : globs) {
if (glob == null || glob.isEmpty()) continue;
try {
if (java.nio.file.FileSystems.getDefault()
.getPathMatcher("glob:" + glob).matches(p)) {
return true;
}
} catch (IllegalArgumentException ignored) {
// A malformed glob from the caller shouldn't crash analysis.
}
}
return false;
}

/** The analysis body; always runs holding {@link #analysisLock}. */
private AnalyzeResponse analyzeLocked(AnalyzeRequest request) {
Path baseDir = Path.of(request.baseDir()).toAbsolutePath().normalize();
Expand Down Expand Up @@ -220,7 +245,9 @@ private AnalyzeResponse analyzeLocked(AnalyzeRequest request) {
"file path escapes the analysis base directory; skipped"));
continue;
}
inputFiles.add(new FileInputFile(file, baseDir, language.get()));
boolean isTest = TestPathDetector.isTest(relative, language.get())
|| matchesAnyGlob(relative, request.additionalTestPaths());
inputFiles.add(new FileInputFile(file, baseDir, language.get(), isTest));
presentLanguages.add(language.get());
}

Expand Down
15 changes: 14 additions & 1 deletion daemon/src/main/java/dev/sonarcli/daemon/FileInputFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,31 @@ public final class FileInputFile implements ClientInputFile {
private final Path absolutePath;
private final String relativePath;
private final SonarLanguage language;
private final boolean isTest;

/**
* @param file the source file (need not be absolute)
* @param baseDir directory the file's relative path is computed against
* @param language the analyzer language for this file
*/
public FileInputFile(Path file, Path baseDir, SonarLanguage language) {
this(file, baseDir, language, false);
}

/**
* @param file the source file (need not be absolute)
* @param baseDir directory the file's relative path is computed against
* @param language the analyzer language for this file
* @param isTest {@code true} to mark this file as test scope; the
* analysis engine then skips MAIN-only rules on it
*/
public FileInputFile(Path file, Path baseDir, SonarLanguage language, boolean isTest) {
this.absolutePath = Objects.requireNonNull(file, "file").toAbsolutePath().normalize();
Path base = Objects.requireNonNull(baseDir, "baseDir").toAbsolutePath().normalize();
// Path.relativize uses the OS separator; the engine expects '/'.
this.relativePath = base.relativize(absolutePath).toString().replace('\\', '/');
this.language = Objects.requireNonNull(language, "language");
this.isTest = isTest;
}

@Override
Expand All @@ -45,7 +58,7 @@ public String getPath() {

@Override
public boolean isTest() {
return false;
return isTest;
}

@Override
Expand Down
107 changes: 107 additions & 0 deletions daemon/src/main/java/dev/sonarcli/daemon/TestPathDetector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package dev.sonarcli.daemon;

import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import org.sonarsource.sonarlint.core.commons.api.SonarLanguage;

/**
* Classifies a source file as production or test code per SonarQube's standard
* {@code sonar.sources} vs {@code sonar.tests} distinction.
*
* <p>SonarSource rules carry a scope ({@code MAIN}, {@code TEST}, {@code ALL});
* the analysis engine skips {@code MAIN}-only rules on files marked as test.
* This is how SonarQube proper lets test code keep its conventional looser
* style (e.g. {@code methodUnderTest_scenario_expected} method names, longer
* methods, generic exception handling) without false positives like
* {@code java:S100}. We do not override any rule here — we only correctly
* classify files; the analyzer's own scope metadata does the rest.
*
* <p>Patterns follow each language's standard build-tool and analyzer
* conventions:
* <pre>
* Java/Kotlin: src/test/**, *Test.{java,kt}, *Tests.{java,kt}, *IT.java
* Scala: src/test/**, *Test.scala
* Go: *_test.go
* Python: tests/**, test/**, test_*.py, *_test.py
* JS/TS: tests/**, __tests__/**, *.{test,spec}.{js,jsx,ts,tsx}
* PHP: tests/**, *Test.php
* Ruby: spec/**, test/**, *_spec.rb, *_test.rb
* CSS/HTML/XML: no test convention; everything is MAIN
* </pre>
*/
public final class TestPathDetector {

/** Path segments that indicate test scope regardless of language. */
private static final List<Pattern> COMMON_PATH_PATTERNS = List.of(
Pattern.compile("(^|/)src/test/"),
Pattern.compile("(^|/)tests?/"),
Pattern.compile("(^|/)__tests__/"),
Pattern.compile("(^|/)spec/"));

/** Per-language filename patterns matched against the file's basename. */
private static final Map<SonarLanguage, List<Pattern>> FILENAME_PATTERNS = Map.ofEntries(
Map.entry(SonarLanguage.JAVA, List.of(
Pattern.compile(".*Test\\.java$"),
Pattern.compile(".*Tests\\.java$"),
Pattern.compile(".*IT\\.java$"))),
Map.entry(SonarLanguage.KOTLIN, List.of(
Pattern.compile(".*Test\\.kt$"),
Pattern.compile(".*Tests\\.kt$"))),
Map.entry(SonarLanguage.SCALA, List.of(
Pattern.compile(".*Test\\.scala$"))),
Map.entry(SonarLanguage.GO, List.of(
Pattern.compile(".*_test\\.go$"))),
Map.entry(SonarLanguage.PYTHON, List.of(
Pattern.compile("test_.*\\.py$"),
Pattern.compile(".*_test\\.py$"))),
Map.entry(SonarLanguage.JS, List.of(
Pattern.compile(".*\\.(test|spec)\\.jsx?$"))),
Map.entry(SonarLanguage.TS, List.of(
Pattern.compile(".*\\.(test|spec)\\.tsx?$"))),
Map.entry(SonarLanguage.PHP, List.of(
Pattern.compile(".*Test\\.php$"))),
Map.entry(SonarLanguage.RUBY, List.of(
Pattern.compile(".*_spec\\.rb$"),
Pattern.compile(".*_test\\.rb$"))));

private TestPathDetector() {
// utility class
}

/**
* @param relativePath '/'-separated path relative to the analysis base
* directory (the engine's standard form)
* @param language the file's detected language; may be {@code null}
* @return {@code true} if the path matches a test convention for the
* given language, {@code false} otherwise
*/
public static boolean isTest(String relativePath, SonarLanguage language) {
if (relativePath == null || relativePath.isEmpty()) {
return false;
}
String normalized = relativePath.replace('\\', '/');

for (Pattern p : COMMON_PATH_PATTERNS) {
if (p.matcher(normalized).find()) {
return true;
}
}

if (language != null) {
List<Pattern> patterns = FILENAME_PATTERNS.get(language);
if (patterns != null) {
int slash = normalized.lastIndexOf('/');
String filename = slash < 0 ? normalized : normalized.substring(slash + 1);
for (Pattern p : patterns) {
if (p.matcher(filename).matches()) {
return true;
}
}
}
}

return false;
}
}
150 changes: 150 additions & 0 deletions daemon/src/test/java/dev/sonarcli/daemon/TestPathDetectorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package dev.sonarcli.daemon;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.sonarsource.sonarlint.core.commons.api.SonarLanguage;

class TestPathDetectorTest {

// --- Path-segment based detection (cross-language) ----------------------

@Test
void srcTestUnderMavenLayoutIsTest() {
assertTrue(TestPathDetector.isTest(
"daemon/src/test/java/dev/sonarcli/daemon/AnalysisServiceTest.java",
SonarLanguage.JAVA));
}

@Test
void topLevelTestsDirIsTest() {
assertTrue(TestPathDetector.isTest("tests/test_thing.py", SonarLanguage.PYTHON));
assertTrue(TestPathDetector.isTest("tests/scanner.spec.ts", SonarLanguage.TS));
}

@Test
void jestUnderscoresDirIsTest() {
assertTrue(TestPathDetector.isTest("src/__tests__/scanner.js", SonarLanguage.JS));
}

@Test
void rspecSpecDirIsTest() {
assertTrue(TestPathDetector.isTest("spec/models/order_spec.rb", SonarLanguage.RUBY));
}

// --- Per-language filename-based detection ------------------------------

@Test
void javaTestSuffixesAreTest() {
assertTrue(TestPathDetector.isTest("foo/bar/AnalysisServiceTest.java", SonarLanguage.JAVA));
assertTrue(TestPathDetector.isTest("foo/bar/AnalysisServiceTests.java", SonarLanguage.JAVA));
assertTrue(TestPathDetector.isTest("foo/bar/AnalysisServiceIT.java", SonarLanguage.JAVA));
}

@Test
void kotlinTestSuffixesAreTest() {
assertTrue(TestPathDetector.isTest("foo/AnalyzerTest.kt", SonarLanguage.KOTLIN));
assertTrue(TestPathDetector.isTest("foo/AnalyzerTests.kt", SonarLanguage.KOTLIN));
}

@Test
void goUnderscoreTestSuffixIsTest() {
assertTrue(TestPathDetector.isTest("cmd/main_test.go", SonarLanguage.GO));
}

@Test
void pythonTestPrefixAndSuffixAreTest() {
assertTrue(TestPathDetector.isTest("a/test_thing.py", SonarLanguage.PYTHON));
assertTrue(TestPathDetector.isTest("a/thing_test.py", SonarLanguage.PYTHON));
}

@Test
void jestAndJasmineSpecsAreTest() {
assertTrue(TestPathDetector.isTest("src/foo.test.js", SonarLanguage.JS));
assertTrue(TestPathDetector.isTest("src/foo.spec.js", SonarLanguage.JS));
assertTrue(TestPathDetector.isTest("src/foo.test.jsx", SonarLanguage.JS));
assertTrue(TestPathDetector.isTest("src/foo.spec.jsx", SonarLanguage.JS));
assertTrue(TestPathDetector.isTest("src/foo.test.ts", SonarLanguage.TS));
assertTrue(TestPathDetector.isTest("src/foo.spec.ts", SonarLanguage.TS));
assertTrue(TestPathDetector.isTest("src/foo.test.tsx", SonarLanguage.TS));
assertTrue(TestPathDetector.isTest("src/foo.spec.tsx", SonarLanguage.TS));
}

@Test
void phpTestSuffixIsTest() {
assertTrue(TestPathDetector.isTest("app/OrderTest.php", SonarLanguage.PHP));
}

@Test
void scalaTestSuffixIsTest() {
assertTrue(TestPathDetector.isTest("foo/AnalyzerTest.scala", SonarLanguage.SCALA));
}

@Test
void rubySpecAndTestSuffixesAreTest() {
assertTrue(TestPathDetector.isTest("models/order_spec.rb", SonarLanguage.RUBY));
assertTrue(TestPathDetector.isTest("models/order_test.rb", SonarLanguage.RUBY));
}

// --- Negatives: production code stays production ------------------------

@Test
void productionJavaPathIsNotTest() {
assertFalse(TestPathDetector.isTest(
"daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java",
SonarLanguage.JAVA));
}

@Test
void productionGoPathIsNotTest() {
assertFalse(TestPathDetector.isTest("cmd/main.go", SonarLanguage.GO));
}

@Test
void productionPythonPathIsNotTest() {
assertFalse(TestPathDetector.isTest("app/service.py", SonarLanguage.PYTHON));
}

@Test
void javaFileNamedSimilarlyButNotASuffixIsNotTest() {
// 'TestHelper' contains 'Test' but the *suffix* is 'Helper'
assertFalse(TestPathDetector.isTest(
"src/main/java/foo/TestHelper.java", SonarLanguage.JAVA));
}

@Test
void htmlXmlCssHaveNoFilenameConventionAndPathSegmentStillWorks() {
// No filename pattern → falls through to path segments
assertFalse(TestPathDetector.isTest("src/main/resources/index.html", SonarLanguage.HTML));
assertFalse(TestPathDetector.isTest("src/main/resources/config.xml", SonarLanguage.XML));
assertFalse(TestPathDetector.isTest("src/main/resources/style.css", SonarLanguage.CSS));
// …but if they ARE under tests/, the path segment marks them as test
assertTrue(TestPathDetector.isTest("tests/fixtures/sample.html", SonarLanguage.HTML));
}

// --- Edge cases ---------------------------------------------------------

@Test
void nullPathIsNotTest() {
assertFalse(TestPathDetector.isTest(null, SonarLanguage.JAVA));
}

@Test
void emptyPathIsNotTest() {
assertFalse(TestPathDetector.isTest("", SonarLanguage.JAVA));
}

@Test
void nullLanguageStillRespectsCommonPathSegments() {
assertTrue(TestPathDetector.isTest("src/test/java/foo/Bar.java", null));
assertFalse(TestPathDetector.isTest("src/main/java/foo/Bar.java", null));
}

@Test
void windowsBackslashPathsAreNormalised() {
assertTrue(TestPathDetector.isTest(
"daemon\\src\\test\\java\\dev\\sonarcli\\daemon\\AnalysisServiceTest.java",
SonarLanguage.JAVA));
}
}
22 changes: 22 additions & 0 deletions plugin/agents/sonar-scanner-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ something unusual; don't guess flag names.
Exit codes: `0` clean, `1` issues found (a normal result, not a failure),
`2` tool error.

## Non-standard test layouts — `--test-path GLOB`

The daemon auto-detects standard test paths per language (`src/test/**`,
`tests/**`, `__tests__/**`, `spec/**`, plus per-language filename
conventions like `*Test.java`, `*_test.go`, `test_*.py`, `*.spec.ts`,
`*_spec.rb`). For those, no flag is needed — `Main`-only rules (`java:S100`,
`java:S106`, `java:S1118`, …) already skip the test files correctly.

**Look at the project layout before scanning.** If you see non-standard
test directories or filename conventions — e.g. `src/integration/`, `e2e/`,
`fixtures/`, `cypress/`, an `acceptance/` tree — pass them as repeatable
`--test-path GLOB` *global* options so the analyzer skips `Main`-only rules
on them too. Globs are standard Java NIO globs against `/`-separated paths.

```sh
# Augment the built-in detection for a project with cypress e2e + integration:
./bin/sonar --test-path 'src/integration/**' --test-path 'cypress/**' agent-scan analyze .
```

Skip this flag when the layout is conventional. Don't over-classify: the
goal is to not flag intentional test conventions, not to silence rules.

## What to report

The stdout summary from `agent-scan` is your top-line: issue count, severity
Expand Down
Loading
Loading