From 5592c100d266c567458e30be384b3cbc0defa662 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 08:59:11 +0000 Subject: [PATCH 1/2] fix: guard isTestoFile against invalid VirtualFile TestoIconProvider could trigger an "Outdated stub in index" exception when invoked on a stale PsiFile whose underlying physical file no longer existed (e.g. a deleted vendor/autoload.php still cached in the stub index). isTestoFile() walked the AST via PsiTreeUtil before any validity check, forcing AST load and crashing on the stub mismatch. Short-circuit isTestoFile() if the file is not a PhpFile, has no VirtualFile, or its VirtualFile is no longer valid - avoiding any PSI traversal in those cases. --- .../kotlin/com/github/xepozz/testo/mixin.kt | 12 +++++++++--- .../com/github/xepozz/testo/MixinPsiTest.kt | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/mixin.kt b/src/main/kotlin/com/github/xepozz/testo/mixin.kt index 5d64112..9dc4947 100644 --- a/src/main/kotlin/com/github/xepozz/testo/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/testo/mixin.kt @@ -43,9 +43,15 @@ fun PsiElement.isTestoClass() = when (this) { else -> false } -fun PsiFile.isTestoFile() = when (this) { - is PhpFile -> TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) || isTestoClassFile() || isTestoFunctionFile() || isTestBenchFile() || isTestoConfigFile() - else -> false +fun PsiFile.isTestoFile(): Boolean { + if (this !is PhpFile) return false + val vFile = virtualFile ?: return false + if (!vFile.isValid) return false + return TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) + || isTestoClassFile() + || isTestoFunctionFile() + || isTestBenchFile() + || isTestoConfigFile() } fun PhpFile.isTestoConfigFile() = PsiTreeUtil.findChildrenOfType(this, ClassReference::class.java) diff --git a/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt index 444052a..836bbd5 100644 --- a/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/MixinPsiTest.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo +import com.intellij.openapi.application.WriteAction import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.TestDataPath import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -155,6 +156,24 @@ class MixinPsiTest : BasePlatformTestCase() { assertTrue("File containing test class should be a Testo file", psiFile.isTestoFile()) } + fun testIsTestoFile_invalidVirtualFile_returnsFalse() { + val psiFile = myFixture.configureByText( + "DeletedTest.php", + """ { vFile.delete(this) } + + assertFalse("VirtualFile should be invalid after deletion", vFile.isValid) + assertFalse( + "isTestoFile must return false when the underlying VirtualFile is invalid", + psiFile.isTestoFile() + ) + } + // ---- isTestoExecutable ---- fun testIsTestoExecutable_testMethod() { From 422d88b30b771806d936113828d5a0f0f3b16eb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 09:52:26 +0000 Subject: [PATCH 2/2] fix: harden isTestoFile against stale stubs and out-of-content files The original report showed an "Outdated stub in index" exception triggered by isTestoFile() called on a stale PsiFile from vendor/ - where the physical file was already deleted but the VFS hadn't refreshed yet, so virtualFile.isValid still returned true. A bare isValid check is therefore not enough. To make isTestoFile() robust: - Skip files outside project content (ProjectFileIndex.isInContent), excluded folders and IDE-ignored paths - this filters out vendor/ and similar locations before any PSI traversal happens, removing the root cause for the reported case. - Wrap the AST-loading traversal in a try/catch that rethrows ProcessCanceledException and downgrades any other Throwable (including StubTreeAndIndexUnmatchCoarseException) to a logged warning + false, so a transient index inconsistency can no longer propagate as an unhandled exception through callers (live templates, inspection suppressor, run config producer, etc.). --- .../kotlin/com/github/xepozz/testo/mixin.kt | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/mixin.kt b/src/main/kotlin/com/github/xepozz/testo/mixin.kt index 2950f69..e653495 100644 --- a/src/main/kotlin/com/github/xepozz/testo/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/testo/mixin.kt @@ -1,6 +1,9 @@ package com.github.xepozz.testo import com.github.xepozz.testo.tests.TestoTestDescriptor +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiTreeUtil @@ -12,6 +15,8 @@ import com.jetbrains.php.lang.psi.elements.ClassReference import com.jetbrains.php.lang.psi.elements.NewExpression import com.jetbrains.php.lang.psi.elements.PhpClass +private val LOG = Logger.getInstance("#com.github.xepozz.testo.mixin") + fun PsiElement.isTestoExecutable() = isTestoFunction() || isTestoMethod() || isTestoBench() fun PsiElement.isTestoBench() = when(this) { @@ -58,11 +63,24 @@ fun PsiFile.isTestoFile(): Boolean { if (this !is PhpFile) return false val vFile = virtualFile ?: return false if (!vFile.isValid) return false - return TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) - || isTestoClassFile() - || isTestoFunctionFile() - || isTestBenchFile() - || isTestoConfigFile() + + val fileIndex = ProjectFileIndex.getInstance(project) + if (!fileIndex.isInContent(vFile)) return false + if (fileIndex.isExcluded(vFile)) return false + if (fileIndex.isUnderIgnored(vFile)) return false + + return try { + TestoTestDescriptor.isTestClassName(name.substringBeforeLast(".")) + || isTestoClassFile() + || isTestoFunctionFile() + || isTestBenchFile() + || isTestoConfigFile() + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Throwable) { + LOG.warn("Failed to determine whether ${vFile.path} is a Testo file", e) + false + } } fun PhpFile.isTestoConfigFile() = PsiTreeUtil.findChildrenOfType(this, ClassReference::class.java)