From 7f89657ddebd0e6433fddbff5c824d1e481fc36d Mon Sep 17 00:00:00 2001 From: Aleksey Dobrynin Date: Thu, 28 May 2026 10:14:02 +0200 Subject: [PATCH] IDEA-389626 JPS: Detect classes shadowed by runtime classpath order GitOrigin-RevId: 233c209122097480ea29523361b3e8e1dc6e3017 --- .../messages/JavaAnalysisBundle.properties | 5 + .../ClassOverriddenAtRuntimeInspection.java | 296 ++++++++++++ .../resources/META-INF/Inspections.xml | 5 + .../ClassOverriddenAtRuntime.html | 17 + .../ClassOverriddenAtRuntimeFixTest.kt | 97 ++++ .../ClassOverriddenAtRuntimeInspectionTest.kt | 420 ++++++++++++++++++ 6 files changed, 840 insertions(+) create mode 100644 java/java-analysis-impl/src/com/intellij/codeInspection/jps/ClassOverriddenAtRuntimeInspection.java create mode 100644 java/java-impl/resources/inspectionDescriptions/ClassOverriddenAtRuntime.html create mode 100644 java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeFixTest.kt create mode 100644 java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeInspectionTest.kt diff --git a/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties b/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties index 2941689e741b..a34af37087df 100644 --- a/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties +++ b/java/java-analysis-api/resources/messages/JavaAnalysisBundle.properties @@ -400,6 +400,11 @@ unknown.guardedby.reference.0.loc=Unknown @GuardedBy reference "{0}" unknown.guardedby.reference.ref.loc=Unknown @GuardedBy reference #ref unnecessary.module.dependency.display.name=Unnecessary module dependency unnecessary.module.dependency.problem.descriptor=Module ''{0}'' sources do not depend on module ''{1}'' sources +class.overridden.at.runtime.display.name=Class loaded from different dependency at runtime +class.overridden.at.runtime.problem=Class ''{0}'' will be loaded from ''{1}'' at runtime, which differs from compile-time resolution +class.overridden.at.runtime.member.problem=Member ''{0}'' of class ''{1}'' may call a different implementation at runtime because the class will be loaded from ''{2}'' +class.overridden.at.runtime.fix.name=Move ''{0}'' before ''{1}'' +class.overridden.at.runtime.fix.family=Move dependency to match compile-time and runtime class resolution unused.import.display.name=Unused import unused.import.statement=Unused import statement unused.library.display.name=Unused library diff --git a/java/java-analysis-impl/src/com/intellij/codeInspection/jps/ClassOverriddenAtRuntimeInspection.java b/java/java-analysis-impl/src/com/intellij/codeInspection/jps/ClassOverriddenAtRuntimeInspection.java new file mode 100644 index 000000000000..ed35a17098a5 --- /dev/null +++ b/java/java-analysis-impl/src/com/intellij/codeInspection/jps/ClassOverriddenAtRuntimeInspection.java @@ -0,0 +1,296 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.codeInspection.jps; + +import com.intellij.codeInspection.AbstractBaseJavaLocalInspectionTool; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemsHolder; +import com.intellij.java.analysis.JavaAnalysisBundle; +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.command.undo.BasicUndoableAction; +import com.intellij.openapi.command.undo.UndoManager; +import com.intellij.openapi.module.Module; +import com.intellij.openapi.module.ModuleUtilCore; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ExternalProjectSystemRegistry; +import com.intellij.openapi.roots.LibraryOrderEntry; +import com.intellij.openapi.roots.ModifiableRootModel; +import com.intellij.openapi.roots.ModuleOrderEntry; +import com.intellij.openapi.roots.ModuleRootManager; +import com.intellij.openapi.roots.ModuleRootModificationUtil; +import com.intellij.openapi.roots.ModuleSourceOrderEntry; +import com.intellij.openapi.roots.OrderEntry; +import com.intellij.openapi.roots.OrderEnumerator; +import com.intellij.openapi.roots.ProjectFileIndex; +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.JavaElementVisitor; +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiElementVisitor; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiImportStatement; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiMember; +import com.intellij.psi.PsiReferenceExpression; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.PsiUtilCore; +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +public final class ClassOverriddenAtRuntimeInspection extends AbstractBaseJavaLocalInspectionTool { + + @Override + public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { + VirtualFile file = holder.getFile().getVirtualFile(); + if (file == null) return PsiElementVisitor.EMPTY_VISITOR; + + Module module = ModuleUtilCore.findModuleForFile(file, holder.getProject()); + if (module == null) return PsiElementVisitor.EMPTY_VISITOR; + + // Only for the IntelliJ module model (JPS) + if (ExternalProjectSystemRegistry.getInstance().getExternalSource(module) != null) return PsiElementVisitor.EMPTY_VISITOR; + boolean includeTests = ProjectFileIndex.getInstance(holder.getProject()).isInTestSourceContent(file); + + return new JavaElementVisitor() { + private final Map myCache = new HashMap<>(); + + @Override + public void visitReferenceExpression(@NotNull PsiReferenceExpression expression) { + checkRef(expression); + } + + @Override + public void visitReferenceElement(@NotNull PsiJavaCodeReferenceElement reference) { + if (reference.getParent() instanceof PsiImportStatement) return; + checkRef(reference); + } + + private void checkRef(@NotNull PsiJavaCodeReferenceElement reference) { + switch (reference.resolve()) { + case PsiClass cls -> checkExplicitClassReference(reference, cls); + case PsiMember member -> checkMemberAccess(reference, member); + case null, default -> { + } + } + } + + private void checkExplicitClassReference(@NotNull PsiJavaCodeReferenceElement reference, @NotNull PsiClass psiClass) { + if (psiClass.getContainingClass() != null) return; + String fqn = psiClass.getQualifiedName(); + if (fqn == null) return; + OverrideInfo info = myCache.computeIfAbsent(fqn, k -> computeOverrideInfo(psiClass, k, module, includeTests)); + if (info == OverrideInfo.EMPTY) return; + String message = JavaAnalysisBundle.message("class.overridden.at.runtime.problem", fqn, info.runtimeEntryName()); + holder.registerProblem(reference, message, fix(info)); + } + + private void checkMemberAccess(@NotNull PsiJavaCodeReferenceElement reference, @NotNull PsiMember member) { + if (!(member.getContainingClass() instanceof PsiClass containingClass)) return; + String fqn = containingClass.getQualifiedName(); + if (fqn == null) return; + OverrideInfo info = myCache.computeIfAbsent(fqn, k -> computeOverrideInfo(containingClass, k, module, includeTests)); + if (info == OverrideInfo.EMPTY) return; + + // A.test() — the "A" class reference is already flagged above; skip to avoid double warning + if (reference.getQualifier() instanceof PsiJavaCodeReferenceElement qualifier && qualifier.resolve() instanceof PsiClass) return; + + // test() called via static import — no explicit class in code; the import itself is already flagged + if (reference instanceof PsiReferenceExpression referenceExpression && referenceExpression.getQualifierExpression() == null) return; + + // Highlight just the member name (e.g. "instanceTest"), not the full qualified expression + PsiElement element = reference instanceof PsiReferenceExpression re ? re.getReferenceNameElement() : null; + String message = JavaAnalysisBundle.message("class.overridden.at.runtime.member.problem", + member.getName(), fqn, info.runtimeEntryName()); + holder.registerProblem(element != null ? element : reference, message, fix(info)); + } + + private static LocalQuickFix @NotNull [] fix(@NotNull OverrideInfo info) { + return new LocalQuickFix[]{new MoveDepBeforeFix(info.compileEntryName(), info.runtimeEntryName())}; + } + }; + } + + private static @NotNull OverrideInfo computeOverrideInfo(@NotNull PsiClass compileClass, + @NotNull String fqn, + @NotNull Module module, + boolean includeTests) { + GlobalSearchScope runtimeScope = module.getModuleRuntimeScope(includeTests); + PsiClass runtimeClass = JavaPsiFacade.getInstance(module.getProject()).findClass(fqn, runtimeScope); + if (runtimeClass == null || compileClass.equals(runtimeClass)) return OverrideInfo.EMPTY; + + VirtualFile runtimeFile = PsiUtilCore.getVirtualFile(runtimeClass); + VirtualFile compileFile = PsiUtilCore.getVirtualFile(compileClass); + if (runtimeFile == null || compileFile == null) return OverrideInfo.EMPTY; + + ProjectFileIndex fileIndex = ProjectFileIndex.getInstance(module.getProject()); + String runtimeEntryName = findTopLevelEntryName(module, fileIndex.getOrderEntriesForFile(runtimeFile), + m -> m.getModuleRuntimeScope(includeTests).contains(runtimeFile)); + String compileEntryName = findTopLevelEntryName(module, fileIndex.getOrderEntriesForFile(compileFile), + m -> containsInExportedCompileScope(m, compileFile, includeTests)); + if (runtimeEntryName == null || compileEntryName == null) return OverrideInfo.EMPTY; + + return new OverrideInfo(runtimeEntryName, compileEntryName); + } + + private static boolean containsInExportedCompileScope(@NotNull Module module, + @NotNull VirtualFile file, + boolean includeTests) { + OrderEnumerator enumerator = OrderEnumerator.orderEntries(module).recursively().exportedOnly().compileOnly(); + if (!includeTests) enumerator = enumerator.productionOnly(); + + if (ContainerUtil.exists(enumerator.classes().usingCache().getRoots(), root -> VfsUtilCore.isAncestor(root, file, false))) return true; + return ContainerUtil.exists(enumerator.sources().usingCache().getRoots(), root -> VfsUtilCore.isAncestor(root, file, false)); + } + + private record OverrideInfo(@NotNull String runtimeEntryName, @NotNull String compileEntryName) { + static OverrideInfo EMPTY = new OverrideInfo("", ""); + } + + private static @Nullable String findTopLevelEntryName(@NotNull Module module, + List fileEntries, + @NotNull Predicate check) { + for (OrderEntry direct : ModuleRootManager.getInstance(module).getOrderEntries()) { + if (direct instanceof ModuleSourceOrderEntry) continue; + + if (direct instanceof ModuleOrderEntry moduleOrderEntry) { + Module dependencyModule = moduleOrderEntry.getModule(); + if (dependencyModule != null && check.test(dependencyModule)) { + return entryName(direct); + } + continue; + } + + if (fileEntries.contains(direct)) { + return entryName(direct); + } + } + + return null; + } + + private static @NonNls @NotNull String entryName(@NotNull OrderEntry entry) { + if (entry instanceof ModuleOrderEntry moe) return moe.getModuleName(); + if (entry instanceof LibraryOrderEntry loe) return Objects.requireNonNullElse(loe.getLibraryName(), ""); + return entry.getPresentableName(); + } + + static final class MoveDepBeforeFix implements LocalQuickFix { + private final String myEntryToMove; + private final String myShadowingEntry; + + MoveDepBeforeFix(@NotNull String entryToMove, @NotNull String shadowingEntry) { + myEntryToMove = entryToMove; + myShadowingEntry = shadowingEntry; + } + + @Override + public @NotNull String getName() { + return JavaAnalysisBundle.message("class.overridden.at.runtime.fix.name", myEntryToMove, myShadowingEntry); + } + + @Override + public @NotNull String getFamilyName() { + return JavaAnalysisBundle.message("class.overridden.at.runtime.fix.family"); + } + + @Override + public boolean startInWriteAction() { + return false; // we own the WriteCommandAction to guarantee undo registration is inside our command + } + + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + PsiElement element = descriptor.getPsiElement(); + if (element == null) return; + + PsiFile containingFile = element.getContainingFile(); + if (containingFile == null) return; + + VirtualFile vFile = containingFile.getVirtualFile(); + if (vFile == null) return; + + Module module = ModuleUtilCore.findModuleForFile(vFile, project); + if (module == null) return; + + String[] originalOrder = Arrays.stream(ModuleRootManager.getInstance(module).getOrderEntries()) + .map(ClassOverriddenAtRuntimeInspection::entryName) + .toArray(String[]::new); + + WriteCommandAction.writeCommandAction(project).run(() -> { + ModifiableRootModel model = ModuleRootManager.getInstance(module).getModifiableModel(); + boolean committed = false; + try { + if (!rearrange(model)) return; + model.commit(); + committed = true; + + UndoManager.getInstance(project).undoableActionPerformed(new BasicUndoableAction(vFile) { + @Override + public void undo() { + if (module.isDisposed()) return; + ModuleRootModificationUtil.updateModel(module, model -> { + Map targetIdx = new HashMap<>(); + for (int i = 0; i < originalOrder.length; i++) { + targetIdx.put(originalOrder[i], i); + } + OrderEntry[] sorted = model.getOrderEntries().clone(); + Arrays.sort(sorted, Comparator.comparingInt(e -> targetIdx.getOrDefault(entryName(e), Integer.MAX_VALUE))); + model.rearrangeOrderEntries(sorted); + }); + } + + @Override + public void redo() { + if (module.isDisposed()) return; + ModuleRootModificationUtil.updateModel(module, model -> rearrange(model)); + } + }); + } + finally { + if (!committed) model.dispose(); + } + }); + } + + private boolean rearrange(ModifiableRootModel model) { + OrderEntry[] entries = model.getOrderEntries(); + int moveIdx = index(entries, myEntryToMove); + int blockerIdx = index(entries, myShadowingEntry); + if (!move(entries, moveIdx, blockerIdx)) return false; + + model.rearrangeOrderEntries(entries); + return true; + } + + private static int index(OrderEntry @NotNull [] entries, @NotNull String expectedName) { + for (int i = 0; i < entries.length; i++) { + if (expectedName.equals(entryName(entries[i]))) { + return i; + } + } + return -1; + } + + private static boolean move(OrderEntry @NotNull [] entries, int moveIdx, int beforeIdx) { + if (beforeIdx >= 0 && moveIdx > beforeIdx && moveIdx < entries.length) { + OrderEntry entryToMove = entries[moveIdx]; + System.arraycopy(entries, beforeIdx, entries, beforeIdx + 1, moveIdx - beforeIdx); + entries[beforeIdx] = entryToMove; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/java/java-backend/resources/META-INF/Inspections.xml b/java/java-backend/resources/META-INF/Inspections.xml index c9ef958f2beb..54254a46e868 100644 --- a/java/java-backend/resources/META-INF/Inspections.xml +++ b/java/java-backend/resources/META-INF/Inspections.xml @@ -14,6 +14,11 @@ + + +Reports class references that will resolve to a different class at runtime than at compile time. +

+Example: +
+  MainModule
+  ├── DepModule
+  │   └── Av1.jar  ← my.example.A (will be used at runtime!)
+  └── Av2.jar      ← my.example.A (used at compile time)
+
+The IDE resolves A from Av2, but the JVM loads it from Av1. +Calling a method that exists only in Av2 causes NoSuchMethodError. +

+Fix: move Av2 before DepModule so both compile and runtime use the same class. + + \ No newline at end of file diff --git a/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeFixTest.kt b/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeFixTest.kt new file mode 100644 index 000000000000..44d4670fba2e --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeFixTest.kt @@ -0,0 +1,97 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.java.codeInspection + +import com.intellij.codeInspection.jps.ClassOverriddenAtRuntimeInspection +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.roots.ModuleOrderEntry +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.testFramework.IndexingTestUtil +import com.intellij.testFramework.LightProjectDescriptor +import com.intellij.testFramework.VfsTestUtil +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import junit.framework.TestCase + +/** + * Tests for [ClassOverriddenAtRuntimeInspection] quick fix — verifies that the fix correctly reorders + * module dependencies so the compile-visible class is loaded first at runtime. + * + * Uses a dedicated descriptor to keep the project isolated from the highlighting tests + * (both share the same test infrastructure but use distinct TempFileSystem paths). + */ +class ClassOverriddenAtRuntimeFixTest : LightJavaCodeInsightFixtureTestCase() { + + override fun getProjectDescriptor(): LightProjectDescriptor = ClassOverriddenAtRuntimeProjectDescriptor + + override fun setUp() { + super.setUp() + myFixture.enableInspections(ClassOverriddenAtRuntimeInspection::class.java) + VfsTestUtil.createFile(ClassOverriddenAtRuntimeProjectDescriptor.av1SourceRoot()!!, "my/example/A.java", + ClassOverriddenAtRuntimeInspectionTest.A_SOURCE) + VfsTestUtil.createFile(ClassOverriddenAtRuntimeProjectDescriptor.av2SourceRoot()!!, "my/example/A.java", + ClassOverriddenAtRuntimeInspectionTest.A_SOURCE) + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + override fun tearDown() { + try { + ClassOverriddenAtRuntimeProjectDescriptor.reset(project) + ClassOverriddenAtRuntimeProjectDescriptor.cleanupSourceRoots() + } + catch (e: Throwable) { + addSuppressedException(e) + } + finally { + super.tearDown() + } + } + + /** Fix correctly moves the compile-side entry (Av2Module) before the shadowing entry (DepModule). */ + fun testFixReordersDependencies() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { A a; } + """.trimIndent()) + myFixture.doHighlighting() + + val mainModule = findMainModule() + assertOrder(mainModule, first = "DepModule", before = "Av2Module") // shadowing order before fix + + myFixture.launchAction(myFixture.findSingleIntention("Move 'Av2Module' before 'DepModule'")) + + assertOrder(mainModule, first = "Av2Module", before = "DepModule") // corrected order after fix + } + + /** After the fix, running the inspection again should produce no warnings. */ + fun testNoWarningAfterFix() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { A a; } + """.trimIndent()) + myFixture.doHighlighting() + myFixture.launchAction(myFixture.findSingleIntention("Move 'Av2Module' before 'DepModule'")) + + IndexingTestUtil.waitUntilIndexesAreReady(project) + + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { A a; } + """.trimIndent()) + myFixture.testHighlighting() // no markers expected + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private fun findMainModule(): Module = + ModuleManager.getInstance(project).findModuleByName(LightProjectDescriptor.TEST_MODULE_NAME)!! + + private fun assertOrder(module: Module, first: String, before: String) { + val entries = ModuleRootManager.getInstance(module).orderEntries + val firstIdx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == first } + val secondIdx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == before } + TestCase.assertTrue( + "Expected '$first' (idx=$firstIdx) to appear before '$before' (idx=$secondIdx)", + firstIdx in 0 until secondIdx + ) + } +} diff --git a/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeInspectionTest.kt b/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeInspectionTest.kt new file mode 100644 index 000000000000..3901420bc903 --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/java/codeInspection/ClassOverriddenAtRuntimeInspectionTest.kt @@ -0,0 +1,420 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.java.codeInspection + +import com.intellij.codeInspection.jps.ClassOverriddenAtRuntimeInspection +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.DependencyScope +import com.intellij.openapi.roots.ModuleOrderEntry +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.ex.temp.TempFileSystem +import com.intellij.pom.java.LanguageLevel +import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.IndexingTestUtil +import com.intellij.testFramework.LightProjectDescriptor +import com.intellij.testFramework.VfsTestUtil +import com.intellij.testFramework.fixtures.DefaultLightProjectDescriptor +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import org.intellij.lang.annotations.Language +import org.jetbrains.jps.model.java.JavaSourceRootType + +class ClassOverriddenAtRuntimeInspectionTest : LightJavaCodeInsightFixtureTestCase() { + + override fun getProjectDescriptor(): LightProjectDescriptor = ClassOverriddenAtRuntimeProjectDescriptor + + override fun setUp() { + super.setUp() + myFixture.enableInspections(ClassOverriddenAtRuntimeInspection::class.java) + // Source files are recreated per test so tests remain independent + VfsTestUtil.createFile(ClassOverriddenAtRuntimeProjectDescriptor.av1SourceRoot()!!, "my/example/A.java", A_SOURCE) + VfsTestUtil.createFile(ClassOverriddenAtRuntimeProjectDescriptor.av2SourceRoot()!!, "my/example/A.java", A_SOURCE) + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + override fun tearDown() { + try { + ClassOverriddenAtRuntimeProjectDescriptor.cleanupSourceRoots() + } + catch (e: Throwable) { + addSuppressedException(e) + } + finally { + super.tearDown() + } + } + + // Expected warning messages (entry names = module names from the descriptor) + private val warningMessage = + "Class 'my.example.A' will be loaded from 'DepModule' at runtime, which differs from compile-time resolution" + + private fun warningMessage(member: String) = + "Member '$member' of class 'my.example.A' may call a different implementation at runtime because the class will be loaded from 'DepModule'" + + fun testTypeDeclaration() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + A a; + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testNewExpression() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + A a = new A(); + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testStaticMethodCall() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + void run() { + A.test(); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testStaticMemberImport() { + myFixture.configureByText("Main.java", """ + import static my.example.A.test; + public class Main { + void run() { test(); } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testStaticWildcardImport() { + myFixture.configureByText("Main.java", """ + import static my.example.A.*; + public class Main { + void run() { test(); } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testInstanceMethodOnTypedVariable() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + void run(A a) { + a.instanceTest(); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + /** Lambda param type is inferred from {@code AConsumer} — no explicit {@code A} at the call site. */ + fun testLambdaWithInferredType() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + interface AConsumer { void consume(A a); } + static void exec(AConsumer c) { c.consume(null); } + void run() { + exec(a -> a.instanceTest()); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testChainedCall() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + static A getA() { return null; } + void run() { + getA().instanceTest(); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testMultipleUsages() { + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + A field; + void m(A arg) { + A local = new A(); + A.test(); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testNoWarningForWildcardImport() { + myFixture.configureByText("Main.java", """ + import my.example.*; + public class Main {} + """.trimIndent()) + myFixture.testHighlighting() + } + + companion object { + @Language("JAVA") + val A_SOURCE = """ + package my.example; + public class A { + public static void test() {} + public void instanceTest() {} + } + """.trimIndent() + } +} + +/** + * Correct module hierarchy mirroring the real-world shadowing scenario: + *
+ *   MainModule
+ *     -> DepModule (COMPILE)                    — intermediate, no A.java itself
+ *         -> Av1Module [COMPILE, not exported]  — provides Av1's A.java, first in DFS
+ *     -> Av2Module (COMPILE)                    — provides Av2's A.java, visible at compile time
+ * 
+ * + * PSI resolves A from Av2Module (Av1Module is not exported -> not in MainModule compile scope). + * DFS runtime order: DepModule first -> recurses into Av1Module -> finds A first -> shadowing. + */ +object ClassOverriddenAtRuntimeProjectDescriptor : DefaultLightProjectDescriptor() { + private const val AV1_SRC = "av1_src" + private const val AV2_SRC = "av2_src" + + fun av1SourceRoot(): VirtualFile? = TempFileSystem.getInstance().findFileByPath("/$AV1_SRC") + fun av2SourceRoot(): VirtualFile? = TempFileSystem.getInstance().findFileByPath("/$AV2_SRC") + + override fun getSdk(): Sdk = IdeaTestUtil.getMockJdk17() + + override fun setUpProject(project: Project, handler: SetupHandler) { + super.setUpProject(project, handler) + + runWriteAction { + val main = ModuleManager.getInstance(project).findModuleByName(TEST_MODULE_NAME)!! + + // Av1Module: provides the shadowing A.java (first in DFS via DepModule) + val av1Module = createModule(project, "${FileUtil.getTempDirectory()}/Av1Module.iml") + ModuleRootModificationUtil.updateModel(av1Module) { model -> + model.sdk = sdk + val src = createSourceRoot(av1Module, AV1_SRC) + model.addContentEntry(src).addSourceFolder(src, JavaSourceRootType.SOURCE) + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + + // DepModule: intermediate module, depends on Av1Module (not exported to MainModule) + val depModule = createModule(project, "${FileUtil.getTempDirectory()}/DepModule.iml") + ModuleRootModificationUtil.updateModel(depModule) { model -> + model.sdk = sdk + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + ModuleRootModificationUtil.addDependency(depModule, av1Module, DependencyScope.COMPILE, false) + + // Av2Module: provides compile-visible A.java (PSI resolves from here) + val av2Module = createModule(project, "${FileUtil.getTempDirectory()}/Av2Module.iml") + ModuleRootModificationUtil.updateModel(av2Module) { model -> + model.sdk = sdk + val src = createSourceRoot(av2Module, AV2_SRC) + model.addContentEntry(src).addSourceFolder(src, JavaSourceRootType.SOURCE) + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + + // MainModule -> [DepModule, Av2Module] + ModuleRootModificationUtil.addDependency(main, depModule) + ModuleRootModificationUtil.addDependency(main, av2Module) + + // Ensure DepModule precedes Av2Module so DFS visits Av1 before Av2 + ensureOrder(main, depModule, av2Module) + } + + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + private fun ensureOrder(main: Module, dep: Module, av2: Module) { + ModuleRootModificationUtil.updateModel(main) { model -> + val entries = model.orderEntries.toMutableList() + val depIdx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == dep.name } + val av2Idx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == av2.name } + if (depIdx > av2Idx && depIdx >= 0 && av2Idx >= 0) { + val e = entries.removeAt(depIdx) + entries.add(av2Idx, e) + model.rearrangeOrderEntries(entries.toTypedArray()) + } + } + } + + fun reset(project: Project) { + val main = ModuleManager.getInstance(project).findModuleByName(TEST_MODULE_NAME) ?: return + ModuleRootModificationUtil.updateModel(main) { model -> + val entries = model.orderEntries.toMutableList() + val depIdx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == "DepModule" } + val av2Idx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == "Av2Module" } + if (depIdx >= 0 && av2Idx >= 0 && depIdx > av2Idx) { + val dep = entries.removeAt(depIdx) + entries.add(av2Idx, dep) + model.rearrangeOrderEntries(entries.toTypedArray()) + } + } + } + + fun cleanupSourceRoots() = runWriteAction { + listOfNotNull(av1SourceRoot(), av2SourceRoot()) + .flatMap { it.children.toList() } + .forEach { it.delete(this) } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Deeper module hierarchy: MainModule → [DepModule → TransModule → Av1Module, Av2Module] +// Verifies that the 3-level transitive chain is detected via DepModule.runtimeScope. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Three-level hierarchy: + *
+ *   MainModule
+ *     -> DepModule (COMPILE)
+ *         -> TransModule (COMPILE, not exported)
+ *             -> Av1Module (COMPILE, not exported)  — provides shadowing A.java
+ *     -> Av2Module (COMPILE)                        — provides compile-visible A.java
+ * 
+ */ +class ClassOverriddenAtRuntimeDeepTest : LightJavaCodeInsightFixtureTestCase() { + override fun getProjectDescriptor(): LightProjectDescriptor = ClassOverriddenAtRuntimeDeepDescriptor + + override fun setUp() { + super.setUp() + myFixture.enableInspections(ClassOverriddenAtRuntimeInspection::class.java) + VfsTestUtil.createFile(ClassOverriddenAtRuntimeDeepDescriptor.av1SourceRoot()!!, "my/example/A.java", + ClassOverriddenAtRuntimeInspectionTest.A_SOURCE) + VfsTestUtil.createFile(ClassOverriddenAtRuntimeDeepDescriptor.av2SourceRoot()!!, "my/example/A.java", + ClassOverriddenAtRuntimeInspectionTest.A_SOURCE) + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + override fun tearDown() { + try { + ClassOverriddenAtRuntimeDeepDescriptor.cleanupSourceRoots() + } + catch (e: Throwable) { + addSuppressedException(e) + } + finally { + super.tearDown() + } + } + + fun testShadowingDetectedInDeepHierarchy() { + val warn = + "Class 'my.example.A' will be loaded from 'DepModule' at runtime, which differs from compile-time resolution" + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { A a; } + """.trimIndent()) + myFixture.testHighlighting() + } + + fun testMemberShadowingDetectedInDeepHierarchy() { + val classWarn = + "Class 'my.example.A' will be loaded from 'DepModule' at runtime, which differs from compile-time resolution" + val memberWarn = + "Member 'instanceTest' of class 'my.example.A' may call a different implementation at runtime because the class will be loaded from 'DepModule'" + myFixture.configureByText("Main.java", """ + import my.example.A; + public class Main { + void run(A a) { + a.instanceTest(); + } + } + """.trimIndent()) + myFixture.testHighlighting() + } +} + +object ClassOverriddenAtRuntimeDeepDescriptor : DefaultLightProjectDescriptor() { + private const val AV1_SRC = "deep_av1_src" + private const val AV2_SRC = "deep_av2_src" + + fun av1SourceRoot(): VirtualFile? = TempFileSystem.getInstance().findFileByPath("/$AV1_SRC") + fun av2SourceRoot(): VirtualFile? = TempFileSystem.getInstance().findFileByPath("/$AV2_SRC") + + override fun getSdk(): Sdk = IdeaTestUtil.getMockJdk17() + + override fun setUpProject(project: Project, handler: SetupHandler) { + super.setUpProject(project, handler) + + runWriteAction { + val main = ModuleManager.getInstance(project).findModuleByName(TEST_MODULE_NAME)!! + + val av1Module = createModule(project, "${FileUtil.getTempDirectory()}/DeepAv1Module.iml") + ModuleRootModificationUtil.updateModel(av1Module) { model -> + model.sdk = sdk + val src = createSourceRoot(av1Module, AV1_SRC) + model.addContentEntry(src).addSourceFolder(src, JavaSourceRootType.SOURCE) + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + + val transModule = createModule(project, "${FileUtil.getTempDirectory()}/TransModule.iml") + ModuleRootModificationUtil.updateModel(transModule) { model -> + model.sdk = sdk + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + ModuleRootModificationUtil.addDependency(transModule, av1Module, DependencyScope.COMPILE, false) + + val depModule = createModule(project, "${FileUtil.getTempDirectory()}/DepModule.iml") + ModuleRootModificationUtil.updateModel(depModule) { model -> + model.sdk = sdk + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + ModuleRootModificationUtil.addDependency(depModule, transModule, DependencyScope.COMPILE, false) + + val av2Module = createModule(project, "${FileUtil.getTempDirectory()}/DeepAv2Module.iml") + ModuleRootModificationUtil.updateModel(av2Module) { model -> + model.sdk = sdk + val src = createSourceRoot(av2Module, AV2_SRC) + model.addContentEntry(src).addSourceFolder(src, JavaSourceRootType.SOURCE) + model.getModuleExtension(com.intellij.openapi.roots.LanguageLevelModuleExtension::class.java).languageLevel = LanguageLevel.JDK_1_8 + } + + ModuleRootModificationUtil.addDependency(main, depModule) + ModuleRootModificationUtil.addDependency(main, av2Module) + ensureOrder(main, depModule, av2Module) + } + + IndexingTestUtil.waitUntilIndexesAreReady(project) + } + + private fun ensureOrder(main: Module, dep: Module, av2: Module) { + ModuleRootModificationUtil.updateModel(main) { model -> + val entries = model.orderEntries.toMutableList() + val depIdx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == dep.name } + val av2Idx = entries.indexOfFirst { it is ModuleOrderEntry && it.moduleName == av2.name } + if (depIdx > av2Idx && depIdx >= 0 && av2Idx >= 0) { + val e = entries.removeAt(depIdx) + entries.add(av2Idx, e) + model.rearrangeOrderEntries(entries.toTypedArray()) + } + } + } + + fun cleanupSourceRoots() = runWriteAction { + listOfNotNull(av1SourceRoot(), av2SourceRoot()) + .flatMap { it.children.toList() } + .forEach { it.delete(this) } + } +}