Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/decompiler-correctness-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ 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, 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
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
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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);

Expand All @@ -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()
{
Expand Down
34 changes: 34 additions & 0 deletions tests/ILInspector.Decompiler.Tests/RenderAbSensorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RenderAbSensor.BaselineMethod>(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<string, RenderAbSensor.RenderedMethod>(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<string, Dictionary<string, string>>(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()
{
Expand Down
159 changes: 159 additions & 0 deletions tests/ILInspector.Decompiler.Tests/ValidityAssignmentContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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);
}

[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()
{
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<string, Dictionary<string, string>>(),
ValidityCheck.MethodShellContext.Create(function, requiresUnsafeContext: false),
productParameterList: null, ValidityCheck.RuntimeReferences(),
new CSharpParseOptions(LanguageVersion.Latest), ValidityCheck.CompileOptions(),
bindSemantics: true);
}
}
7 changes: 5 additions & 2 deletions tools/DecompilerHarness/CorpusSensor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.HasSemanticOutcome),
results.Count(result => result.HasSemanticDefect));
}

Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -2275,6 +2277,7 @@ internal static ImmutableArray<string> 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}");
Expand Down
11 changes: 9 additions & 2 deletions tools/DecompilerHarness/LibraryReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading