From ab8b45ba3eb35efc1c113bb2e9691c35853806e2 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Mon, 12 Oct 2026 17:01:56 -0700 Subject: [PATCH 1/3] Report unresolved out-call assignment context as unavailable validity evidence --- docs/decompiler-correctness-pipeline.md | 22 +++ .../CorpusSensorComparisonTests.cs | 16 ++- .../RenderAbSensorTests.cs | 34 +++++ .../ValidityAssignmentContextTests.cs | 129 ++++++++++++++++++ tools/DecompilerHarness/CorpusSensor.cs | 7 +- tools/DecompilerHarness/LibraryReport.cs | 11 +- tools/DecompilerHarness/RenderAbSensor.cs | 15 +- tools/DecompilerHarness/ValidityCheck.cs | 103 +++++++++++++- 8 files changed, 326 insertions(+), 11 deletions(-) create mode 100644 tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs diff --git a/docs/decompiler-correctness-pipeline.md b/docs/decompiler-correctness-pipeline.md index 790d662708..241c9f19bd 100644 --- a/docs/decompiler-correctness-pipeline.md +++ b/docs/decompiler-correctness-pipeline.md @@ -70,6 +70,28 @@ accidentally compile a different second projection. Missing fully qualified target-assembly namespaces remain explicit shell-visibility noise (`CS0400`); the harness does not reference the inspected assembly and accidentally bind a different product version already present in its own runtime closure. +When CS0165 names a local passed to an unresolved `out` invocation in the same +executable body, Roslyn's error recovery cannot establish that call's assignment +effect. The same limitation can affect a bound `out` call in an `&&` guard +containing another unresolved `out` call, when the read is in the guard's true +arm. The validity lane reports `VLD0002` semantic coverage unavailable, +retaining the raw CS0165 and unresolved-call evidence in its message. It does +not claim validity, remove a product initializer, or repair the call. Independent +defects in that body remain defects; a bound skipped-out-call read remains +CS0165. Corpus and Render A/B report this decline rather than a validity success. +The motivating real asset is `dotnet-inspect.any` 0.14.0, its +`ILInspector.Decompiler.Pipeline.TypeRef::DisplayName` body: the standalone shell +cannot bind receiver properties or `s_keywords.TryGetValue`, while native RTS +compiles the unchanged product artifact. `ValidityAssignmentContextTests` +retains that observed body and bound-call, skipped-call, unrelated-local, and +nested-body controls. The same package's `CSharpPrinter::EnumMemberName` and +`Microsoft.CodeAnalysis.Common` 5.0.0's `AnalyzerConfig::get_GlobalLevel` +motivate the dependent bound-call case: a first unresolved dictionary lookup +precedes a bound dictionary lookup or `int.TryParse` in the same `&&` guard. +The controls retain that dependency and keep reads after the guard or under +`||` defective when the bound call can be skipped. +This is a diagnostic coverage fix for #9939, not a new +compilation-context composer or a native fidelity contract change. `CompilerFeatureOptionsTests.RuntimeAsyncUnsafeSpillBeforeAwait_ClosesUnsafeRunAndBindsFirstProjection` gates this ownership boundary with compiler-produced runtime-async IL. diff --git a/tests/ILInspector.Decompiler.Tests/CorpusSensorComparisonTests.cs b/tests/ILInspector.Decompiler.Tests/CorpusSensorComparisonTests.cs index a8b65fbd64..549fda617c 100644 --- a/tests/ILInspector.Decompiler.Tests/CorpusSensorComparisonTests.cs +++ b/tests/ILInspector.Decompiler.Tests/CorpusSensorComparisonTests.cs @@ -1357,6 +1357,7 @@ public void Compare_PinnedValidityLossCannotBeOffsetByGain() [Theory] [InlineData("not-sampled")] [InlineData("syntax-valid")] + [InlineData("semantic-unavailable:VLD0002")] public void Compare_PinnedValidityCoverageChangeIsNotARegression(string currentValidity) { var baseline = Snapshot( @@ -1370,7 +1371,7 @@ public void Compare_PinnedValidityCoverageChangeIsNotARegression(string currentV 1, 10_000, [SnapshotMethod("Stable", validity: currentValidity)], - semanticCheckedMethods: currentValidity == "syntax-valid" ? 0 : 1); + semanticCheckedMethods: currentValidity is "syntax-valid" or "semantic-unavailable:VLD0002" ? 0 : 1); var regressions = CorpusSensor.PinnedMethodRegressions(baseline, current); @@ -1381,6 +1382,19 @@ public void Compare_PinnedValidityCoverageChangeIsNotARegression(string currentV StringComparison.Ordinal)); } + [Fact] + public void Compare_AssignmentContextDeclineStillFailsCoverageGate() + { + var baseline = Snapshot(1, 1, 10_000, + [SnapshotMethod("Stable", validity: "valid")], semanticCheckedMethods: 1); + var current = Snapshot(1, 1, 10_000, + [SnapshotMethod("Stable", validity: "semantic-unavailable:VLD0002")], semanticCheckedMethods: 0); + + var regressions = CorpusSensor.Compare(baseline, current, [], gateAggregateRates: false); + Assert.Contains("semantic checked methods lower than baseline (baseline 1, current 0)", regressions); + Assert.DoesNotContain(regressions, regression => regression.StartsWith("valid method regressed (pinned)", StringComparison.Ordinal)); + } + [Fact] public void Compare_PinnedMethodGateFailsClosedWithoutBaselineLedger() { diff --git a/tests/ILInspector.Decompiler.Tests/RenderAbSensorTests.cs b/tests/ILInspector.Decompiler.Tests/RenderAbSensorTests.cs index 2ca560f427..8bc18e8d59 100644 --- a/tests/ILInspector.Decompiler.Tests/RenderAbSensorTests.cs +++ b/tests/ILInspector.Decompiler.Tests/RenderAbSensorTests.cs @@ -52,6 +52,40 @@ public void RenderAbMatchesMetadataBackedProductProjection(Type type, string met Assert.Equal(expected, actual); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RenderAbSemanticLane_UnresolvedOutCallReportsCoverageDecline(bool independentDefect) + { + const string key = "fixture.dll!T::M()"; + var function = SyntheticFunction(); + var shellContext = ValidityCheck.MethodShellContext.Create(function, requiresUnsafeContext: false); + var baseline = new Dictionary(StringComparer.Ordinal) + { + [key] = new("int value = default; if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value)) return value; return 0;", shellContext), + }; + var current = new Dictionary(StringComparer.Ordinal) + { + [key] = new("T", "M", "()", typeof(RenderAbSensorTests).Assembly.Location, + "fixture.dll", "int value; if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value)) return value; " + + (independentDefect ? "int other; return other;" : "return 0;"), shellContext, + new RenderAbSensor.SemanticContext("T", "M", function, + new Dictionary>(StringComparer.Ordinal), + ProductParameterList: null)), + }; + + // Structural correspondence is deliberately absent in this focused shell + // fixture, so the existing independent structural gate still fails. + string output = CaptureConsole(() => RenderAbSensor.Compare(baseline, current, maxExamples: 5), expectedExitCode: 2); + Assert.Contains($"valid->invalid: {(independentDefect ? 1 : 0)}", output); + Assert.Contains($"unavailable: {(independentDefect ? 0 : 1)}", output); + Assert.Contains("VLD0002", output); + Assert.Contains("CS0165", output); + Assert.Contains("Missing", output); + if (independentDefect) + Assert.Contains("Use of unassigned local variable 'other'", output); + } + [Fact] public void RenderAbSemanticLane_CatchesParseValidCompileInvalidRegression() { diff --git a/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs b/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs new file mode 100644 index 0000000000..5f43a33151 --- /dev/null +++ b/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs @@ -0,0 +1,129 @@ +using ILInspector.Decompiler.Pipeline; +using ILInspector.DecompilerHarness; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ILInspector.Decompiler.Tests; + +[Trait("Area", "Validity")] +public class ValidityAssignmentContextTests +{ + // Observed unchanged body from dotnet-inspect.any 0.14.0, + // ILInspector.Decompiler.Pipeline.TypeRef::DisplayName (#9939). + [Fact] + public void DisplayNameMissingReceiverContext_DeclinesAssignmentCoverage() + { + var result = Check(""" + string keyword; + if (Assembly == "corelib" && Namespace == "System" && TypeRef.s_keywords.TryGetValue(Name, out keyword)) + return keyword; + int nested = Name.LastIndexOf('+'); + return CSharpNaming.TypeNameSegment(nested < 0 ? Name : Name.Substring(nested + 1)); + """, "string"); + + Assert.True(result.SemanticUnavailable); + Assert.False(result.IsValid); + Assert.False(result.HasSemanticDefect); + var diagnostic = Assert.Single(result.SemanticDiagnostics); + Assert.Equal(ValidityCheck.AssignmentContextUnavailableId, diagnostic.Id); + Assert.Contains("CS0165", diagnostic.Message); + Assert.Contains("TypeRef.s_keywords.TryGetValue", diagnostic.Message); + } + + [Theory] + [InlineData("int value; if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value)) return value; return 0;")] + [InlineData("if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out int value)) return value; return 0;")] + public void UnresolvedOutCall_DeclinesRatherThanProvesValidity(string body) + { + var result = Check(body); + Assert.True(result.SemanticUnavailable); + Assert.False(result.IsValid); + Assert.False(result.HasSemanticDefect); + } + + [Theory] + [InlineData("string text; int value; if (Missing.TryRead(out text) && int.TryParse(text, out value)) return value; return 0;")] + [InlineData("string text; int value; if ((Missing.TryRead(out text) && (int.TryParse(text, out value)))) return value; return 0;")] + public void BoundOutCallAfterUnresolvedOutInAndGuard_DeclinesDependentCoverage(string body) + { + var result = Check(body); + Assert.True(result.SemanticUnavailable); + Assert.False(result.HasSemanticDefect); + Assert.False(result.IsValid); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == ValidityCheck.AssignmentContextUnavailableId + && d.Message.Contains("'value'", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("string text; int value; if (Missing.TryRead(out text) || int.TryParse(\"1\", out value)) return value; return 0;")] + [InlineData("string text; int value; if (Missing.TryRead(out text) && int.TryParse(\"1\", out value)) { } return value;")] + public void BoundOutCallThatCanBeSkipped_StaysDefectiveDespiteUnresolvedSibling(string body) + { + var result = Check(body); + Assert.True(result.HasSemanticDefect); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == "CS0165" && d.Message.Contains("'value'", StringComparison.Ordinal)); + } + + [Fact] + public void BoundOutCall_ProvesAssignment() + { + var result = Check("int value; if (int.TryParse(\"1\", out value)) return value; return 0;"); + Assert.True(result.IsValid); + Assert.False(result.SemanticUnavailable); + } + + [Fact] + public void BoundSkippedOutCall_KeepsRealDefect() + { + var result = Check("int value; if (System.DateTime.Now.Ticks > 0 && int.TryParse(\"1\", out value)) { } return value;"); + Assert.True(result.HasSemanticDefect); + Assert.False(result.SemanticUnavailable); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == "CS0165"); + } + + [Theory] + [InlineData("int other; Missing.TryRead(out other); int value; return value;")] + [InlineData("int value; void Nested() { Missing.TryRead(out value); } return value;")] + [InlineData("int value; Missing.TryRead(ref value); return value;")] + [InlineData("int value; int copy = value; Missing.TryRead(out value); return copy;")] + public void UnrelatedOrNestedOrRefCall_KeepsRealDefect(string body) + { + var result = Check(body); + Assert.True(result.HasSemanticDefect); + Assert.False(result.SemanticUnavailable); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == "CS0165"); + } + + [Fact] + public void UnresolvedBareCallWithoutCs0165_DoesNotInventCoverageDecline() + { + // Roslyn still tracks the explicit out assignment for this error-recovery + // shape. Only an actual CS0165 with missing assignment context declines. + var result = Check("int value; if (Missing(out value)) return value; return 0;"); + Assert.False(result.SemanticUnavailable); + Assert.DoesNotContain(result.SemanticDiagnostics, d => d.Id == "CS0165"); + } + + [Fact] + public void IndependentDefectSurvivesCoverageDecline() + { + var result = Check("int value; if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value)) return value; int other; return other;"); + Assert.True(result.SemanticUnavailable); + Assert.True(result.HasSemanticDefect); + Assert.False(result.IsValid); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == "CS0165" && d.Message.Contains("other", StringComparison.Ordinal)); + } + + static ValidityCheck.RenderedBodyResult Check(string body, string returnType = "int") + { + var function = new IrFunction("M", TypeRef.Definition("fixture", "", "Host"), + new MethodSignature(TypeRef.CoreLib("System", returnType == "int" ? "Int32" : "String"), + [], HasThis: false, GenericParameterCount: 0), [], new BlockContainer()); + return ValidityCheck.EvaluateRenderedBody(function, body, "Host", "M", + new Dictionary>(), + ValidityCheck.MethodShellContext.Create(function, requiresUnsafeContext: false), + productParameterList: null, ValidityCheck.RuntimeReferences(), + new CSharpParseOptions(LanguageVersion.Latest), ValidityCheck.CompileOptions(), + bindSemantics: true); + } +} diff --git a/tools/DecompilerHarness/CorpusSensor.cs b/tools/DecompilerHarness/CorpusSensor.cs index 73ae8b1261..23054d175a 100644 --- a/tools/DecompilerHarness/CorpusSensor.cs +++ b/tools/DecompilerHarness/CorpusSensor.cs @@ -1004,7 +1004,7 @@ static ValiditySensorMetrics AnalyzeValidity( } return new ValiditySensorMetrics( results.Count(result => result.IsFull && result.IsMalformed), - results.Count(result => result.SemanticChecked), + results.Count(result => result.SemanticChecked && (!result.SemanticUnavailable || result.HasSemanticDefect)), results.Count(result => result.HasSemanticDefect)); } @@ -1950,7 +1950,9 @@ static string ValidityStatus(ValidityCheck.MethodResult result) if (result.SemanticChecked) return result.HasSemanticDefect ? $"semantic-defect:{Codes(result.SemanticDiagnostics)}" - : "valid"; + : result.SemanticUnavailable + ? $"semantic-unavailable:{Codes(result.SemanticDiagnostics)}" + : "valid"; return "syntax-valid"; } @@ -2275,6 +2277,7 @@ internal static ImmutableArray PinnedMethodRegressions( failures.Add($"Full fidelity method lost (pinned): {methodKey} -> {outcome}"); } if (before.Validity == "valid" + && !after.Validity.StartsWith("semantic-unavailable:", StringComparison.Ordinal) && after.Validity is not ("valid" or "not-sampled" or "syntax-valid")) { failures.Add($"valid method regressed (pinned): {methodKey} -> {after.Validity}"); diff --git a/tools/DecompilerHarness/LibraryReport.cs b/tools/DecompilerHarness/LibraryReport.cs index e1bb383b9d..47e1f3b913 100644 --- a/tools/DecompilerHarness/LibraryReport.cs +++ b/tools/DecompilerHarness/LibraryReport.cs @@ -125,6 +125,7 @@ static AssemblyReport AnalyzeAssembly( report.AvailableMethods = source.Reader.MethodDefinitions .Count(handle => source.Reader.GetMethodDefinition(handle).RelativeVirtualAddress != 0); var constraints = ShellConstraints.Build(source); + int semanticAttempts = 0; foreach (var (typeName, methodName, function) in IrImporter.ImportAssemblyStableSample(source, methodCap)) { @@ -257,11 +258,12 @@ static AssemblyReport AnalyzeAssembly( continue; } - if (!full || report.SemanticChecked >= compileCap) + if (!full || semanticAttempts >= compileCap) { continue; } report.SemanticChecked++; + semanticAttempts++; var compilation = CSharpCompilation.Create("check", [tree], references, compileOptions); var semanticModel = compilation.GetSemanticModel(tree); @@ -270,12 +272,17 @@ static AssemblyReport AnalyzeAssembly( tree, function, semanticModel); + bool assignmentUnavailable = defects.Any(d => d.Id == ValidityCheck.AssignmentContextUnavailableId); + bool hasDefect = defects.Any(d => d.Id != ValidityCheck.AssignmentContextUnavailableId); + if (assignmentUnavailable && !hasDefect) + report.SemanticChecked--; if (defects.Length == 0) { continue; } - report.SemanticDefectMethods++; + if (hasDefect) + report.SemanticDefectMethods++; foreach (var defect in defects) { Record(buckets, $"validity: {defect.Id}", id, maxExamples); diff --git a/tools/DecompilerHarness/RenderAbSensor.cs b/tools/DecompilerHarness/RenderAbSensor.cs index 846d95b230..ade592ef24 100644 --- a/tools/DecompilerHarness/RenderAbSensor.cs +++ b/tools/DecompilerHarness/RenderAbSensor.cs @@ -381,6 +381,7 @@ internal static int Compare( var changes = new List(); var unavailableChanges = new List(); var semanticRegressions = new List<(string Key, string Before, string After, ValidityCheck.RenderedBodyResult BeforeValidity, ValidityCheck.RenderedBodyResult AfterValidity)>(); + var assignmentUnavailable = new List<(string Key, ValidityCheck.ValidityDiagnostic Diagnostic)>(); var compileOptions = ValidityCheck.CompileOptions(); var semanticContexts = new Dictionary(StringComparer.Ordinal); var featureOptionsByAssembly = @@ -462,6 +463,9 @@ internal static int Compare( featureOptions, compileOptions); var transition = SemanticTransitionOf(beforeValidity, afterValidity); + if (afterValidity.SemanticUnavailable) + foreach (var diagnostic in Diagnostics(afterValidity)) + assignmentUnavailable.Add((kvp.Key, diagnostic)); bySemantic[transition]++; if (transition == SemanticTransition.ValidToInvalid) semanticRegressions.Add((kvp.Key, before.Body, sample.Body, beforeValidity, afterValidity)); @@ -577,6 +581,13 @@ internal static int Compare( ?? change.Before.CompileBackUnavailableReason)); } + if (assignmentUnavailable.Count > 0) + { + Console.WriteLine($"Semantic assignment context unavailable: {assignmentUnavailable.Select(x => x.Key).Distinct().Count()}"); + foreach (var (key, diagnostic) in assignmentUnavailable.Take(maxExamples)) + Console.WriteLine($" {key}: {diagnostic.Id}: {diagnostic.Message}"); + } + if (semanticRegressions.Count > 0) { Console.WriteLine(); @@ -754,6 +765,8 @@ enum SemanticTransition static SemanticTransition SemanticTransitionOf(ValidityCheck.RenderedBodyResult before, ValidityCheck.RenderedBodyResult after) => before.CompileBackUnavailable || after.CompileBackUnavailable + || (before.SemanticUnavailable && !before.HasSemanticDefect) + || (after.SemanticUnavailable && !after.HasSemanticDefect) ? SemanticTransition.Unavailable : (before.IsValid, after.IsValid) switch { @@ -950,4 +963,4 @@ sealed record StructuralArtifactEntry( bool? CorrespondenceComplete, string? Artifact, string? Failure); -} \ No newline at end of file +} diff --git a/tools/DecompilerHarness/ValidityCheck.cs b/tools/DecompilerHarness/ValidityCheck.cs index 4ae8f6a951..af09221460 100644 --- a/tools/DecompilerHarness/ValidityCheck.cs +++ b/tools/DecompilerHarness/ValidityCheck.cs @@ -40,7 +40,8 @@ static class ValidityCheck // as noise. Genuine misuse codes (CS0029/CS0266 conversions — the // constrained-generic family; CS0165 use-before-assignment; CS0136/CS0128 // duplicate locals; CS0193 deref of non-pointer; CS1656/CS0131 bad assign) - // are deliberately KEPT. + // are deliberately KEPT. Missing out-call assignment context is a coverage + // decline (VLD0002), retaining the raw CS0165 in its evidence message. // // CS1729 ("'T' does not contain a constructor that takes N arguments") is the // constructor analog of CS1501 (the method-arity/visibility code already @@ -65,6 +66,7 @@ static class ValidityCheck ]; internal const string StructuredEvidenceMissId = "VLD0001"; + internal const string AssignmentContextUnavailableId = "VLD0002"; static readonly HashSet ShellTypeDiagnosticIds = [ @@ -86,7 +88,8 @@ public sealed record MethodResult( { public string Id => $"{TypeName}::{MethodName}"; public bool IsMalformed => MalformedDiagnostics.Length > 0; - public bool HasSemanticDefect => SemanticDiagnostics.Length > 0; + public bool HasSemanticDefect => SemanticDiagnostics.Any(d => d.Id != AssignmentContextUnavailableId); + public bool SemanticUnavailable => SemanticDiagnostics.Any(d => d.Id == AssignmentContextUnavailableId); public bool CompileBackUnavailable => CompileBackUnavailableReason is not null; } @@ -98,10 +101,12 @@ internal sealed record RenderedBodyResult( string? CompileBackUnavailableReason = null) { public bool IsMalformed => MalformedDiagnostics.Length > 0; - public bool HasSemanticDefect => SemanticDiagnostics.Length > 0; + public bool HasSemanticDefect => SemanticDiagnostics.Any(d => d.Id != AssignmentContextUnavailableId); + public bool SemanticUnavailable => SemanticDiagnostics.Any(d => d.Id == AssignmentContextUnavailableId); public bool CompileBackUnavailable => CompileBackUnavailableReason is not null; public bool IsValid => !CompileBackUnavailable + && !SemanticUnavailable && !IsMalformed && SemanticChecked && !HasSemanticDefect; @@ -134,7 +139,8 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples int fullMalformed = results.Count(r => r.IsFull && r.IsMalformed); int partialMalformed = results.Count(r => !r.IsFull && r.IsMalformed); int semanticEligible = results.Count(r => r.IsFull && !r.IsMalformed); - int semChecked = results.Count(r => r.SemanticChecked); + int semChecked = results.Count(r => r.SemanticChecked && (!r.SemanticUnavailable || r.HasSemanticDefect)); + int semanticUnavailable = results.Count(r => r.SemanticUnavailable); int semDefect = results.Count(r => r.HasSemanticDefect); var defectCodes = new SortedDictionary(StringComparer.Ordinal); foreach (var diagnostic in results.SelectMany(r => r.SemanticDiagnostics)) @@ -147,7 +153,11 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples var defectExamples = results .Where(r => r.HasSemanticDefect) .Take(maxExamples) - .Select(r => $"{r.Id}\n {r.SemanticDiagnostics[0].Id}: {r.SemanticDiagnostics[0].Message}") + .Select(r => + { + var defect = r.SemanticDiagnostics.First(d => d.Id != AssignmentContextUnavailableId); + return $"{r.Id}\n {defect.Id}: {defect.Message}"; + }) .ToList(); var unavailableExamples = results .Where(r => r.CompileBackUnavailable) @@ -175,6 +185,10 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples Report(total, fullTotal, partialTotal, fullMalformed, partialMalformed, compileBackUnavailable, semanticEligible, semChecked, cap, semDefect, defectCodes, malformedExamples, defectExamples, unavailableExamples); + Console.WriteLine($"Semantic assignment context unavailable: {semanticUnavailable}"); + foreach (var result in results.Where(r => r.SemanticUnavailable).Take(maxExamples)) + foreach (var diagnostic in result.SemanticDiagnostics.Where(d => d.Id == AssignmentContextUnavailableId)) + Console.WriteLine($" {result.Id}: {diagnostic.Id}: {diagnostic.Message}"); if (methodDefects is not null && emitDefectsPath is not null) EmitDefects(emitDefectsPath, methodDefects); @@ -493,6 +507,12 @@ internal static ImmutableArray ClassifySemanticDiagnostics( if (!IsError(diagnostic) || BindingNoise.Contains(diagnostic.Id)) continue; + if (UnresolvedOutAssignment(diagnostic, tree, semanticModel) is { } unavailable) + { + defects.Add(new ValidityDiagnostic(AssignmentContextUnavailableId, unavailable)); + continue; + } + var shellArtifact = ClassifyShellArtifact(diagnostic, tree, function, semanticModel); if (shellArtifact == EvidenceDisposition.Filter) continue; @@ -524,6 +544,79 @@ internal static ImmutableArray ClassifySemanticDiagnostics( return defects.ToImmutable(); } + // Match symbols, not spelling: another local or a nested executable body + // cannot supply assignment evidence for the diagnosed read. This declines + // coverage, never proves the unresolved call would make the body valid. + static string? UnresolvedOutAssignment( + Diagnostic diagnostic, + SyntaxTree tree, + SemanticModel semanticModel) + { + if (diagnostic.Id != "CS0165" || diagnostic.Location.SourceTree != tree) + return null; + var read = tree.GetRoot().FindNode(diagnostic.Location.SourceSpan, + getInnermostNodeForTie: true); + if (semanticModel.GetSymbolInfo(read).Symbol is not ILocalSymbol local) + return null; + var scope = ExecutableScope(read); + if (scope is null) + return null; + foreach (var argument in scope.DescendantNodes() + .OfType()) + { + if (!argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword) + || ExecutableScope(argument) != scope + || argument.Parent?.Parent is not InvocationExpressionSyntax invocation + || invocation.SpanStart > read.SpanStart) + continue; + var assigned = argument.Expression is DeclarationExpressionSyntax declaration + && declaration.Designation is SingleVariableDesignationSyntax designation + ? semanticModel.GetDeclaredSymbol(designation) + : semanticModel.GetSymbolInfo(argument.Expression).Symbol; + if (!SymbolEqualityComparer.Default.Equals(local, assigned)) + continue; + var unresolved = semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol + ? invocation + : UnresolvedOutInAndGuard(invocation, read, semanticModel); + if (unresolved is not null) + return $"{diagnostic.Id}: {diagnostic.GetMessage()}; assignment context unavailable: unresolved out invocation '{unresolved.Expression}'"; + } + return null; + } + + static InvocationExpressionSyntax? UnresolvedOutInAndGuard( + InvocationExpressionSyntax invocation, + SyntaxNode read, + SemanticModel semanticModel) + { + // A resolved second out call can also lose its true-outcome facts when + // an earlier out call in the same && guard cannot bind. Limit this + // dependent decline to reads protected by that guard's true arm: a read + // after the if, or under ||, can genuinely skip the resolved call. + SyntaxNode condition = invocation; + bool hasAnd = false; + while (condition.Parent is ParenthesizedExpressionSyntax + || condition.Parent is BinaryExpressionSyntax binary + && binary.IsKind(SyntaxKind.LogicalAndExpression)) + { + hasAnd |= condition.Parent is BinaryExpressionSyntax; + condition = condition.Parent; + } + if (!hasAnd + || condition.Parent is not IfStatementSyntax guard + || !guard.Statement.Span.Contains(read.Span)) + return null; + return condition.DescendantNodes().OfType() + .FirstOrDefault(candidate => ExecutableScope(candidate) == ExecutableScope(read) + && candidate.ArgumentList.Arguments.Any(argument => argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword)) + && semanticModel.GetSymbolInfo(candidate).Symbol is not IMethodSymbol); + } + + static SyntaxNode? ExecutableScope(SyntaxNode node) + => node.Ancestors().FirstOrDefault(ancestor => ancestor is + BaseMethodDeclarationSyntax or AccessorDeclarationSyntax + or LocalFunctionStatementSyntax or AnonymousFunctionExpressionSyntax); + static void Record(Dictionary> map, string method, IEnumerable codes) { if (!map.TryGetValue(method, out var set)) From 9884a6ad00a3e5b8af5c125d630dd4a009897fab Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Mon, 12 Oct 2026 17:10:20 -0700 Subject: [PATCH 2/3] Separate validity compilation attempts from completed outcomes --- docs/decompiler-correctness-pipeline.md | 4 ++++ tools/DecompilerHarness/CorpusSensor.cs | 2 +- tools/DecompilerHarness/ValidityCheck.cs | 13 ++++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/decompiler-correctness-pipeline.md b/docs/decompiler-correctness-pipeline.md index 241c9f19bd..ff73bc7605 100644 --- a/docs/decompiler-correctness-pipeline.md +++ b/docs/decompiler-correctness-pipeline.md @@ -79,6 +79,10 @@ retaining the raw CS0165 and unresolved-call evidence in its message. It does not claim validity, remove a product initializer, or repair the call. Independent defects in that body remain defects; a bound skipped-out-call read remains CS0165. Corpus and Render A/B report this decline rather than a validity success. +Context-only declines do not count as completed semantic checks; a row with an +independent defect retains its failed outcome. Attempted compilations are +reported separately, so increasing the sample cap is not offered as a remedy +for missing binding context. The motivating real asset is `dotnet-inspect.any` 0.14.0, its `ILInspector.Decompiler.Pipeline.TypeRef::DisplayName` body: the standalone shell cannot bind receiver properties or `s_keywords.TryGetValue`, while native RTS diff --git a/tools/DecompilerHarness/CorpusSensor.cs b/tools/DecompilerHarness/CorpusSensor.cs index 23054d175a..fc9f602696 100644 --- a/tools/DecompilerHarness/CorpusSensor.cs +++ b/tools/DecompilerHarness/CorpusSensor.cs @@ -1004,7 +1004,7 @@ static ValiditySensorMetrics AnalyzeValidity( } return new ValiditySensorMetrics( results.Count(result => result.IsFull && result.IsMalformed), - results.Count(result => result.SemanticChecked && (!result.SemanticUnavailable || result.HasSemanticDefect)), + results.Count(result => result.HasSemanticOutcome), results.Count(result => result.HasSemanticDefect)); } diff --git a/tools/DecompilerHarness/ValidityCheck.cs b/tools/DecompilerHarness/ValidityCheck.cs index af09221460..e502098e40 100644 --- a/tools/DecompilerHarness/ValidityCheck.cs +++ b/tools/DecompilerHarness/ValidityCheck.cs @@ -90,6 +90,7 @@ public sealed record MethodResult( public bool IsMalformed => MalformedDiagnostics.Length > 0; public bool HasSemanticDefect => SemanticDiagnostics.Any(d => d.Id != AssignmentContextUnavailableId); public bool SemanticUnavailable => SemanticDiagnostics.Any(d => d.Id == AssignmentContextUnavailableId); + public bool HasSemanticOutcome => SemanticChecked && (!SemanticUnavailable || HasSemanticDefect); public bool CompileBackUnavailable => CompileBackUnavailableReason is not null; } @@ -139,7 +140,8 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples int fullMalformed = results.Count(r => r.IsFull && r.IsMalformed); int partialMalformed = results.Count(r => !r.IsFull && r.IsMalformed); int semanticEligible = results.Count(r => r.IsFull && !r.IsMalformed); - int semChecked = results.Count(r => r.SemanticChecked && (!r.SemanticUnavailable || r.HasSemanticDefect)); + int semanticAttempts = results.Count(r => r.SemanticChecked); + int semChecked = results.Count(r => r.HasSemanticOutcome); int semanticUnavailable = results.Count(r => r.SemanticUnavailable); int semDefect = results.Count(r => r.HasSemanticDefect); var defectCodes = new SortedDictionary(StringComparer.Ordinal); @@ -183,7 +185,7 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples } Report(total, fullTotal, partialTotal, fullMalformed, partialMalformed, - compileBackUnavailable, semanticEligible, semChecked, cap, semDefect, + compileBackUnavailable, semanticEligible, semanticAttempts, semChecked, cap, semDefect, defectCodes, malformedExamples, defectExamples, unavailableExamples); Console.WriteLine($"Semantic assignment context unavailable: {semanticUnavailable}"); foreach (var result in results.Where(r => r.SemanticUnavailable).Take(maxExamples)) @@ -693,7 +695,7 @@ static void PrintDiffSide(string title, SortedDictionary> b static void Report( int total, int fullTotal, int partialTotal, int fullMalformed, int partialMalformed, - int compileBackUnavailable, int semanticEligible, int semChecked, int cap, + int compileBackUnavailable, int semanticEligible, int semanticAttempts, int semChecked, int cap, int semDefect, SortedDictionary defectCodes, List malformedExamples, List defectExamples, List unavailableExamples) @@ -709,9 +711,10 @@ static void Report( Console.WriteLine($" Full malformed : {fullMalformed} ({Pct(fullMalformed, fullTotal)} of Full) — the \"claimed good but won't parse\" set"); Console.WriteLine($" Partial malformed: {partialMalformed} ({Pct(partialMalformed, partialTotal)} of Partial) — expected: the diagnosed unsupported bits"); Console.WriteLine(); - Console.WriteLine($"Semantic binding (Full + syntactically-valid): compiled {semChecked} of {semanticEligible} eligible methods (compile-cap {capText})"); - if (semChecked < semanticEligible) + Console.WriteLine($"Semantic binding (Full + syntactically-valid): compiled {semanticAttempts} of {semanticEligible} eligible methods (compile-cap {capText})"); + if (semanticAttempts < semanticEligible) Console.WriteLine(" NOTE: semantic findings are per-sample, not corpus-wide; raise --compile-cap or use --compile-cap all for an exhaustive binding sweep."); + Console.WriteLine($" completed semantic outcomes: {semChecked}"); Console.WriteLine($" with a non-binding-noise error: {semDefect} ({Pct(semDefect, semChecked)})"); if (defectCodes.Count > 0) { From 3ff30fe9dd81f38aab48f3b52db0558aaa1d9bdd Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Mon, 12 Oct 2026 17:48:46 -0700 Subject: [PATCH 3/3] Preserve skipped unresolved out-call defects in validity checks --- docs/decompiler-correctness-pipeline.md | 7 +- .../ValidityAssignmentContextTests.cs | 30 +++++ tools/DecompilerHarness/ValidityCheck.cs | 106 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/docs/decompiler-correctness-pipeline.md b/docs/decompiler-correctness-pipeline.md index ff73bc7605..9d23262e44 100644 --- a/docs/decompiler-correctness-pipeline.md +++ b/docs/decompiler-correctness-pipeline.md @@ -71,8 +71,11 @@ target-assembly namespaces remain explicit shell-visibility noise (`CS0400`); the harness does not reference the inspected assembly and accidentally bind a different product version already present in its own runtime closure. When CS0165 names a local passed to an unresolved `out` invocation in the same -executable body, Roslyn's error recovery cannot establish that call's assignment -effect. The same limitation can affect a bound `out` call in an `&&` guard +executable body, and the call is required on the recognized path to the read, +Roslyn's error recovery cannot establish that call's assignment effect. +Conditional calls that can be skipped before the read retain CS0165, whether +bound or unresolved. Unrecognized control flow also retains the defect. +The same limitation can affect a bound `out` call in an `&&` guard containing another unresolved `out` call, when the read is in the guard's true arm. The validity lane reports `VLD0002` semantic coverage unavailable, retaining the raw CS0165 and unresolved-call evidence in its message. It does diff --git a/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs b/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs index 5f43a33151..a3bf214865 100644 --- a/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs +++ b/tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs @@ -72,6 +72,36 @@ public void BoundOutCall_ProvesAssignment() Assert.False(result.SemanticUnavailable); } + [Theory] + [InlineData("int value; if (System.DateTime.Now.Ticks > 0) Missing.TryRead(out value); return value;")] + [InlineData("int value; if (System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value)) { } return value;")] + [InlineData("int value; if (System.DateTime.Now.Ticks > 0 || Missing.TryRead(out value)) return value; return 0;")] + [InlineData("int value; while (System.DateTime.Now.Ticks > 0) Missing.TryRead(out value); return value;")] + [InlineData("int value; bool ignored = System.DateTime.Now.Ticks > 0 ? Missing.TryRead(out value) : false; return value;")] + [InlineData("int value; try { Missing.TryRead(out value); } catch { } return value;")] + [InlineData("int value; if (System.DateTime.Now.Ticks > 0) goto Read; Missing.TryRead(out value); Read: return value;")] + [InlineData("int value; do { if (System.DateTime.Now.Ticks > 0) continue; Missing.TryRead(out value); } while (value > 0); return 0;")] + public void UnresolvedOutCallThatCanBeSkipped_KeepsRealDefect(string body) + { + var result = Check(body); + Assert.True(result.HasSemanticDefect); + Assert.False(result.SemanticUnavailable); + Assert.Contains(result.SemanticDiagnostics, d => d.Id == "CS0165"); + } + + [Theory] + [InlineData("int value; if (System.DateTime.Now.Ticks <= 0 || !Missing.TryRead(out value)) return 0; return value;")] + [InlineData("int value; return System.DateTime.Now.Ticks <= 0 || !Missing.TryRead(out value) ? 0 : value;")] + [InlineData("int value; return System.DateTime.Now.Ticks > 0 && Missing.TryRead(out value) && value > 0 ? 1 : 0;")] + [InlineData("int value; if (System.DateTime.Now.Ticks <= 0 || !Missing.TryRead(out value) || value <= 0) return 0; return 1;")] + public void UnresolvedOutCallOnRequiredGuardPath_DeclinesCoverage(string body) + { + var result = Check(body); + Assert.True(result.SemanticUnavailable); + Assert.False(result.HasSemanticDefect); + Assert.False(result.IsValid); + } + [Fact] public void BoundSkippedOutCall_KeepsRealDefect() { diff --git a/tools/DecompilerHarness/ValidityCheck.cs b/tools/DecompilerHarness/ValidityCheck.cs index e502098e40..cfe93135d1 100644 --- a/tools/DecompilerHarness/ValidityCheck.cs +++ b/tools/DecompilerHarness/ValidityCheck.cs @@ -577,6 +577,8 @@ internal static ImmutableArray ClassifySemanticDiagnostics( : semanticModel.GetSymbolInfo(argument.Expression).Symbol; if (!SymbolEqualityComparer.Default.Equals(local, assigned)) continue; + if (!OutCallMustExecuteBeforeRead(invocation, read, semanticModel)) + continue; var unresolved = semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol ? invocation : UnresolvedOutInAndGuard(invocation, read, semanticModel); @@ -586,6 +588,110 @@ internal static ImmutableArray ClassifySemanticDiagnostics( return null; } + static bool OutCallMustExecuteBeforeRead(InvocationExpressionSyntax invocation, SyntaxNode read, SemanticModel semanticModel) + { + // Missing binding cannot explain a read on a path that skips the call. + // Only recognize syntax whose execution requirement is explicit; unknown + // control flow retains the compiler's defect rather than declining it. + var scope = ExecutableScope(invocation); + if (scope?.DescendantNodes().OfType().Any() == true) + return false; + for (SyntaxNode node = invocation; node.Parent is { } parent; node = parent) + { + if (parent is BinaryExpressionSyntax binary && binary.Right.Span.Contains(invocation.Span)) + { + if (binary.IsKind(SyntaxKind.CoalesceExpression) && !binary.Right.Span.Contains(read.Span)) + return false; + if ((binary.IsKind(SyntaxKind.LogicalAndExpression) || binary.IsKind(SyntaxKind.LogicalOrExpression)) + && semanticModel.GetSymbolInfo(binary).Symbol is IMethodSymbol) + return false; + if (binary.IsKind(SyntaxKind.LogicalAndExpression) + && ExpressionOutcomeAtRead(binary, read) != true) + return false; + if (binary.IsKind(SyntaxKind.LogicalOrExpression) + && ExpressionOutcomeAtRead(binary, read) != false) + return false; + } + if (parent is ConditionalAccessExpressionSyntax access + && access.WhenNotNull.Span.Contains(invocation.Span)) + return false; + if (parent is SwitchSectionSyntax or SwitchExpressionArmSyntax or CatchClauseSyntax or FinallyClauseSyntax + && !parent.Span.Contains(read.Span)) + return false; + if (parent is TryStatementSyntax exceptionRegion && exceptionRegion.Block.Span.Contains(invocation.Span) + && !exceptionRegion.Block.Span.Contains(read.Span)) + return false; + var loopBody = parent switch + { + WhileStatementSyntax loop => loop.Statement, + ForStatementSyntax loop => loop.Statement, + ForEachStatementSyntax loop => loop.Statement, + DoStatementSyntax loop => loop.Statement, + _ => null + }; + if (loopBody?.Span.Contains(invocation.Span) == true && !loopBody.Span.Contains(read.Span)) + return false; + if (parent is ConditionalExpressionSyntax conditional + && !conditional.Condition.Span.Contains(invocation.Span) + && !(conditional.WhenTrue.Span.Contains(invocation.Span) && conditional.WhenTrue.Span.Contains(read.Span)) + && !(conditional.WhenFalse.Span.Contains(invocation.Span) && conditional.WhenFalse.Span.Contains(read.Span))) + return false; + if (parent is IfStatementSyntax guard + && !guard.Condition.Span.Contains(invocation.Span) + && !(guard.Statement.Span.Contains(invocation.Span) && guard.Statement.Span.Contains(read.Span)) + && !(guard.Else is { } alternative && alternative.Statement.Span.Contains(invocation.Span) + && alternative.Statement.Span.Contains(read.Span))) + return false; + if (parent is WhileStatementSyntax or ForStatementSyntax or ForEachStatementSyntax + or DoStatementSyntax or SwitchStatementSyntax or TryStatementSyntax) + { + if (!parent.Span.Contains(read.Span)) + return false; + } + if (parent == ExecutableScope(invocation)) + break; + } + return true; + } + + static bool? ExpressionOutcomeAtRead(ExpressionSyntax expression, SyntaxNode read) + { + if (expression.Parent is BinaryExpressionSyntax continuation + && continuation.Left == expression && continuation.Right.Span.Contains(read.Span)) + { + if (continuation.IsKind(SyntaxKind.LogicalAndExpression)) + return true; + if (continuation.IsKind(SyntaxKind.LogicalOrExpression)) + return false; + } + if (expression.Span.Contains(read.Span)) + return null; + return expression.Parent switch + { + ParenthesizedExpressionSyntax parentheses => ExpressionOutcomeAtRead(parentheses, read), + PrefixUnaryExpressionSyntax negation when negation.IsKind(SyntaxKind.LogicalNotExpression) + => ExpressionOutcomeAtRead(negation, read) is { } outcome ? !outcome : null, + BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.LogicalAndExpression) + => ExpressionOutcomeAtRead(binary, read) == true ? true : null, + BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.LogicalOrExpression) + => ExpressionOutcomeAtRead(binary, read) == false ? false : null, + ConditionalExpressionSyntax conditional when conditional.Condition == expression + => conditional.WhenTrue.Span.Contains(read.Span) ? true + : conditional.WhenFalse.Span.Contains(read.Span) ? false : null, + IfStatementSyntax guard when guard.Condition == expression + => guard.Statement.Span.Contains(read.Span) ? true + : guard.Else?.Statement.Span.Contains(read.Span) == true ? false + : guard.Span.End <= read.SpanStart && AlwaysExits(guard.Statement) ? false + : guard.Span.End <= read.SpanStart && guard.Else is { } alternative + && AlwaysExits(alternative.Statement) ? true : null, + _ => null + }; + } + + static bool AlwaysExits(StatementSyntax statement) + => statement is ReturnStatementSyntax or ThrowStatementSyntax + || statement is BlockSyntax block && block.Statements.LastOrDefault() is { } last && AlwaysExits(last); + static InvocationExpressionSyntax? UnresolvedOutInAndGuard( InvocationExpressionSyntax invocation, SyntaxNode read,