1919import com .sonar .sslr .api .AstNode ;
2020import com .sonar .sslr .api .RecognitionException ;
2121import java .io .File ;
22+ import javax .annotation .Nullable ;
2223import java .io .IOException ;
2324import java .util .ArrayList ;
2425import java .util .Collection ;
5657import org .sonar .plugins .python .telemetry .collectors .TestFileTelemetryCollector ;
5758import org .sonar .plugins .python .telemetry .collectors .TypeInferenceTelemetry ;
5859import org .sonar .plugins .python .telemetry .collectors .TypeInferenceTelemetryCollector ;
60+ import org .sonar .plugins .python .warnings .AnalysisWarningsWrapper ;
5961import org .sonar .python .IPythonLocation ;
6062import org .sonar .python .SubscriptionVisitor ;
6163import org .sonar .python .parser .PythonParser ;
@@ -85,15 +87,24 @@ public class PythonScanner extends Scanner {
8587 private final Lock lock ;
8688 private final TypeInferenceTelemetryCollector typeInferenceTelemetryCollector ;
8789 private final TestFileTelemetryCollector testFileTelemetryCollector ;
90+ private final boolean testSourcesConfigured ;
91+ private final AnalysisWarningsWrapper analysisWarnings ;
92+ private final AtomicBoolean heuristicWarningEmitted = new AtomicBoolean (false );
93+ static final String UNSET_SONAR_TESTS_WARNING = "SonarPython detected files that look like test code " +
94+ "but 'sonar.tests' is not configured. Rules targeting production code were not executed on these files. " +
95+ "Configure 'sonar.tests' in your project properties for a more accurate analysis." ;
8896
8997 public PythonScanner (
9098 SensorContext context , PythonChecks checks , FileLinesContextFactory fileLinesContextFactory , NoSonarFilter noSonarFilter ,
91- Supplier <PythonParser > parserSupplier , PythonIndexer indexer , PythonFileConsumer architectureCallback , NoSonarLineInfoCollector noSonarLineInfoCollector ) {
99+ Supplier <PythonParser > parserSupplier , PythonIndexer indexer , PythonFileConsumer architectureCallback ,
100+ NoSonarLineInfoCollector noSonarLineInfoCollector , AnalysisWarningsWrapper analysisWarnings ) {
92101 super (context );
93102 this .checks = checks ;
94103 this .parserSupplier = parserSupplier ;
95104 this .indexer = indexer ;
96105 this .noSonarLineInfoCollector = noSonarLineInfoCollector ;
106+ this .analysisWarnings = analysisWarnings ;
107+ this .testSourcesConfigured = TestFileClassifier .isTestSourceConfigured (context .config ());
97108 this .indexer .buildOnce (context );
98109 this .architectureCallback = architectureCallback ;
99110 this .checksExecutedWithoutParsingByFiles = new ConcurrentHashMap <>();
@@ -125,9 +136,14 @@ protected void scanFile(PythonInputFile inputFile) throws IOException {
125136 var pythonFile = SonarQubePythonFile .create (inputFile );
126137 InputFile .Type fileType = inputFile .wrappedFile ().type ();
127138 PythonVisitorContext visitorContext = createVisitorContext (inputFile , pythonFile );
139+ InputFile .Type effectiveTypeForRules = resolveEffectiveTypeForRules (
140+ fileType , projectRelativePath (inputFile ), visitorContext .rootTree ());
141+ if (!testSourcesConfigured && fileType == InputFile .Type .MAIN ) {
142+ indexer .writeEffectiveFileType (inputFile .wrappedFile ().key (), effectiveTypeForRules );
143+ }
128144
129- executeChecks (visitorContext , checks .sonarPythonChecks (), fileType , inputFile );
130- executeOtherChecks (inputFile , visitorContext , fileType );
145+ executeChecks (visitorContext , checks .sonarPythonChecks (), effectiveTypeForRules , inputFile );
146+ executeOtherChecks (inputFile , visitorContext , effectiveTypeForRules );
131147
132148
133149 runLockedByRepository (ARCHITECTURE_CALLBACK_LOCK_KEY , () -> architectureCallback .scanFile (visitorContext ));
@@ -241,6 +257,11 @@ private static Map<Integer, IPythonLocation> getOffsetLocations(PythonInputFile
241257 @ Override
242258 public boolean scanFileWithoutParsing (PythonInputFile inputFile ) {
243259 InputFile .Type fileType = inputFile .wrappedFile ().type ();
260+ InputFile .Type effectiveTypeForRules = resolveEffectiveTypeForRulesFromCache (
261+ fileType , projectRelativePath (inputFile ), inputFile .wrappedFile ().key ());
262+ if (effectiveTypeForRules == null ) {
263+ return false ;
264+ }
244265 boolean result = true ;
245266 PythonFile pythonFile = SonarQubePythonFile .create (inputFile .wrappedFile ());
246267 PythonInputFileContext inputFileContext = new PythonInputFileContext (
@@ -251,13 +272,13 @@ public boolean scanFileWithoutParsing(PythonInputFile inputFile) {
251272 indexer .projectLevelSymbolTable ()
252273 );
253274
254- result = scanFileWithoutParsingSonarPython (inputFile , fileType , inputFileContext , result );
275+ result = scanFileWithoutParsingSonarPython (inputFile , effectiveTypeForRules , inputFileContext , result );
255276
256277 var atomicResult = new AtomicBoolean (result );
257278 var otherChecks = checks .noSonarPythonChecks ();
258279 otherChecks .forEach ((repositoryKey , repositoryChecks ) -> runLockedByRepository (repositoryKey , () -> {
259280 for (var check : repositoryChecks ) {
260- var scanResult = scanFileWithoutParsingNotSonarPython (inputFile , check , fileType , atomicResult .get (), inputFileContext );
281+ var scanResult = scanFileWithoutParsingNotSonarPython (inputFile , check , effectiveTypeForRules , atomicResult .get (), inputFileContext );
261282 atomicResult .set (scanResult );
262283 }
263284 }));
@@ -322,6 +343,49 @@ private void endOfAnalysisForRepository(String repositoryKey, List<EndOfAnalysis
322343 runLockedByRepository (repositoryKey , () -> endOfAnalyses .forEach (c -> c .endOfAnalysis (indexer .cacheContext ())));
323344 }
324345
346+ private String projectRelativePath (PythonInputFile inputFile ) {
347+ return context .fileSystem ().baseDir ().toURI ()
348+ .relativize (inputFile .wrappedFile ().uri ())
349+ .toString ();
350+ }
351+
352+ private boolean isBypassed (InputFile .Type platformType ) {
353+ return testSourcesConfigured || platformType == InputFile .Type .TEST ;
354+ }
355+
356+ private InputFile .Type resolveEffectiveTypeForRules (InputFile .Type platformType , String filePath , @ Nullable FileInput tree ) {
357+ if (isBypassed (platformType )) {
358+ return platformType ;
359+ }
360+ boolean isTest = TestFileClassifier .looksLikeTestFile (filePath , tree );
361+ if (isTest ) {
362+ maybeEmitHeuristicWarning ();
363+ return InputFile .Type .TEST ;
364+ }
365+ return InputFile .Type .MAIN ;
366+ }
367+
368+ @ Nullable
369+ private InputFile .Type resolveEffectiveTypeForRulesFromCache (InputFile .Type platformType , String filePath , String fileKey ) {
370+ if (isBypassed (platformType )) {
371+ return platformType ;
372+ }
373+ InputFile .Type cached = indexer .readEffectiveFileType (fileKey );
374+ if (cached == null ) {
375+ // Cache entry is missing while the file is unchanged — the cache is in an inconsistent state
376+ // This should never happen in normal operation. Fall back to a full parse rather than guessing the type.
377+ LOG .debug ("No cached effective file type for '{}': triggering full parse" , filePath );
378+ }
379+ return cached ;
380+ }
381+
382+ private void maybeEmitHeuristicWarning () {
383+ if (heuristicWarningEmitted .compareAndSet (false , true )) {
384+ LOG .warn (UNSET_SONAR_TESTS_WARNING );
385+ analysisWarnings .addUnique (UNSET_SONAR_TESTS_WARNING );
386+ }
387+ }
388+
325389 boolean isCheckNotApplicable (PythonCheck pythonCheck , InputFile .Type fileType ) {
326390 return fileType != InputFile .Type .MAIN && pythonCheck .scope () != PythonCheck .CheckScope .ALL ;
327391 }
0 commit comments