diff --git a/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs
new file mode 100644
index 000000000..559c63bc4
--- /dev/null
+++ b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs
@@ -0,0 +1,144 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX.Analyzers;
+
+using ILSpy.Analyzers;
+using ILSpy.AppEnv;
+using ILSpy.TreeNodes;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Analyzers;
+
+///
+/// Regression: analysing the "Used By" of a constructor must not throw. The reported
+/// user-visible failure was an InvalidCastException in the live app; this test exercises
+/// the same path (analyzer enumeration + the search-node WrapResult switch) but bypasses
+/// the search node's catch so any exception bubbles up to NUnit with its stack trace.
+///
+[TestFixture]
+public class AnalyzerConstructorUsesTests
+{
+ [AvaloniaTest]
+ public async Task Used_By_Analyzer_Runs_Over_A_Constructor_Without_Throwing()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ // Pick any concrete type with at least one public instance ctor; the type itself
+ // doesn't matter — what matters is that the symbol fed to the analyzer is a method
+ // whose IsConstructor is true.
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "System.Linq", "System.Linq", "System.Linq.Lookup`2");
+ typeNode.EnsureLazyChildren();
+ var ctorTreeNode = typeNode.Children.OfType()
+ .FirstOrDefault(m => m.MethodDefinition.IsConstructor);
+ (ctorTreeNode is null).Should().BeFalse(
+ "Lookup defines at least one constructor — the test depends on it");
+ var ctor = (IMethod)ctorTreeNode!.Member!;
+ ctor.IsConstructor.Should().BeTrue();
+
+ // Resolve the "Used By" analyzer that actually applies to this method.
+ var analyzer = AnalyzerTreeNode.Analyzers
+ .Where(a => a.Metadata?.Header == "Used By")
+ .Select(a => a.CreateExport().Value)
+ .First(a => a.Show(ctor));
+
+ // Drive Analyze directly (no AnalyzerSearchTreeNode → no swallowing catch). Take
+ // a small slice so we don't pay for a full scan; the bug repros on the first call
+ // site if it's going to throw at all.
+ var assemblyList = vm.AssemblyTreeModel.AssemblyList!;
+ var context = new AnalyzerContext {
+ CancellationToken = CancellationToken.None,
+ Language = AppComposition.Current.GetExport().CurrentLanguage,
+ AssemblyList = assemblyList,
+ };
+ var results = analyzer.Analyze(ctor, context).Take(20).ToList();
+ results.Should().NotBeEmpty("constructors of public LINQ types must have at least one in-tree caller");
+ }
+
+ [AvaloniaTest]
+ public async Task Uses_Analyzer_Runs_Over_A_Constructor_Without_Throwing()
+ {
+ // "Uses" is the forward direction (what does the body of this method touch?).
+ // MethodUsesAnalyzer can return type/method/field/property/event ISymbols — the
+ // path most likely to surface a switch-arm hole for some result shape we missed.
+
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "System.Linq", "System.Linq", "System.Linq.Lookup`2");
+ typeNode.EnsureLazyChildren();
+ var ctor = (IMethod)typeNode.Children.OfType()
+ .First(m => m.MethodDefinition.IsConstructor).Member!;
+
+ var analyzer = AnalyzerTreeNode.Analyzers
+ .Where(a => a.Metadata?.Header == "Uses")
+ .Select(a => a.CreateExport().Value)
+ .First(a => a.Show(ctor));
+
+ var assemblyList = vm.AssemblyTreeModel.AssemblyList!;
+ var context = new AnalyzerContext {
+ CancellationToken = CancellationToken.None,
+ Language = AppComposition.Current.GetExport().CurrentLanguage,
+ AssemblyList = assemblyList,
+ };
+ var results = analyzer.Analyze(ctor, context).Take(20).ToList();
+ results.Should().NotBeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Analyze_Pushes_A_Constructor_Onto_The_Pane_Without_Throwing()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "System.Linq", "System.Linq", "System.Linq.Lookup`2");
+ typeNode.EnsureLazyChildren();
+ var ctor = (IMethod)typeNode.Children.OfType()
+ .First(m => m.MethodDefinition.IsConstructor).Member!;
+
+ var analyzerVm = AppComposition.Current.GetExport();
+ var beforeCount = analyzerVm.Root.Children.Count;
+
+ // This was the user-reported throw point.
+ analyzerVm.Analyze(ctor);
+
+ analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1);
+ }
+}
diff --git a/ILSpy/Languages/Language.cs b/ILSpy/Languages/Language.cs
index 3f0547d1c..fefc1533d 100644
--- a/ILSpy/Languages/Language.cs
+++ b/ILSpy/Languages/Language.cs
@@ -56,11 +56,21 @@ namespace ILSpy.Languages
public bool HasLanguageVersions => LanguageVersions.Count > 0;
///
- /// Token-to-source-line mapping for navigation. Default returns a no-op CodeMappingInfo —
- /// language subclasses with full decompilation override and produce a real one.
+ /// Token-to-source-line mapping for navigation. Default walks
+ /// up to its declaring type and hands back a keyed on
+ /// that type. The the caller passes may name any member
+ /// kind (method, field, …) — analyzers like MethodUsesAnalyzer pass the
+ /// method's own token and expect the language to walk to the type itself, not crash
+ /// trying to cast the method handle. Subclasses with full decompilation override and
+ /// build the mapping from the decompiler pipeline.
///
public virtual CodeMappingInfo GetCodeMappingInfo(MetadataFile module, EntityHandle member)
- => new(module, (TypeDefinitionHandle)member);
+ {
+ var declaringType = (TypeDefinitionHandle)member.GetDeclaringType(module.Metadata);
+ if (declaringType.IsNil && member.Kind == HandleKind.TypeDefinition)
+ declaringType = (TypeDefinitionHandle)member;
+ return new CodeMappingInfo(module, declaringType);
+ }
///
/// Stable name string for an entity reachable only by metadata token. Falls back to the