Skip to content

Commit 1f6aef8

Browse files
Bound Exhaustion analysis and strengthen client contract coverage (#147)
# TLDR; Bound Exhaustion's analysis of large closed hierarchies so constructor expansion cannot exhaust memory, and strengthen behavioral coverage across the analyzer, HTTP clients, result types, generator, and samples. Fix request-header overrides, upload-stream disposal, and invalid generated response aliases exposed by that coverage. # Details - Give each switch an independent budget of 1,024 materialized combinations, 16,384 analysis steps, and 64 levels of traversal. Check products before allocation, propagate compiler cancellation, and report `EXHAUSTION002` when complete coverage cannot be determined within those limits. - Merge default and request headers case-insensitively, with request values taking precedence while preserving repeated values within the selected header. - Override `HttpContent.Dispose(bool)` so disposing upload content through `HttpContent`, `IDisposable`, or its owning request closes the source stream. - Generate compilable primitive, collection, and nullable response aliases, and qualify `Outcome.Unit` to avoid collisions with generated models. Regenerate the affected sample clients. - Upgrade `Microsoft.OpenApi` and `Microsoft.OpenApi.YamlReader` from 2.3.5 to 2.7.5 in both generators and the generator tests. - Remove unnecessary null suppressions from URL parsing, error-response reading, and a response assertion. - Replace the inherited-generic test's inconclusive exit with source and metadata assertions, and allow the F# test SDK to generate its entry point. # How Do The Tests Prove The Changes Work? - Run real SqlParserCS 0.6.5 AST switches in a child process with a 512 MiB heap limit and a timeout. Assert controlled diagnostics, exact switch locations, bounded allocations and execution time, and absence of analyzer exceptions. - Exercise large source and metadata hierarchies, combination overflow and boundary cases, deep traversal, cancellation, independent switch budgets, and edit/remove/restore cycles with exact diagnostic assertions. - Assert HTTP request methods, URIs, bodies, headers, response payloads, callback counts, failures, cancellation, retries, and resource ownership across multiple interactions. Verify header overrides do not leak between requests and each disposal route closes the upload stream. - Compile generated clients and aliases with Roslyn, covering primitive and collection responses, nullable aliases, and models named `Unit`; verify deterministic output and regeneration. - Add result-contract assertions and multi-step UI and live API checks for state transitions, payloads, identities, collection contents, and repeated operations. Fixes #146.
1 parent c296b18 commit 1f6aef8

48 files changed

Lines changed: 6605 additions & 496 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
using System.Collections.Immutable;
2+
using System.Globalization;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Diagnostics;
7+
using Microsoft.CodeAnalysis.Testing;
8+
using VerifyCS = Microsoft.CodeAnalysis.CSharp.Testing.MSTest.AnalyzerVerifier<Exhaustion.ExhaustionAnalyzer>;
9+
10+
namespace Exhaustion.Tests;
11+
12+
#pragma warning disable CA1506 // Exercises Roslyn syntax editing, compilation, and diagnostic contracts together.
13+
#pragma warning disable SA1600
14+
15+
internal static class AnalyzerInteractionAssertions
16+
{
17+
private static readonly ImmutableArray<MetadataReference> RuntimeReferences =
18+
[
19+
.. (
20+
(string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")
21+
?? throw new InvalidOperationException("Runtime assembly references are unavailable.")
22+
)
23+
.Split(Path.PathSeparator)
24+
.Distinct(StringComparer.Ordinal)
25+
.Select(path => MetadataReference.CreateFromFile(path)),
26+
];
27+
28+
internal static async Task VerifyAsync(string source, params DiagnosticResult[] expected)
29+
{
30+
// Preserve the original verifier's exact diagnostics, markup spans, and compiler-error expectations.
31+
await VerifyCS.VerifyAnalyzerAsync(source, expected).ConfigureAwait(false);
32+
var unmarkedSource = source
33+
.Replace("{|#0:", string.Empty, StringComparison.Ordinal)
34+
.Replace("|}", string.Empty, StringComparison.Ordinal);
35+
var compilation = CSharpCompilation.Create(
36+
"AnalyzerInteractionFixture",
37+
[
38+
CSharpSyntaxTree.ParseText(
39+
unmarkedSource,
40+
new CSharpParseOptions(LanguageVersion.CSharp12)
41+
),
42+
],
43+
RuntimeReferences,
44+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
45+
);
46+
var diagnostics = await compilation
47+
.WithAnalyzers([new ExhaustionAnalyzer()])
48+
.GetAnalyzerDiagnosticsAsync()
49+
.ConfigureAwait(false);
50+
var expectedAnalyzerIds = expected
51+
.Where(result => result.Id.StartsWith("EXHAUSTION", StringComparison.Ordinal))
52+
.Select(result => result.Id)
53+
.Order(StringComparer.Ordinal)
54+
.ToArray();
55+
CollectionAssert.AreEqual(
56+
expectedAnalyzerIds,
57+
diagnostics.Select(diagnostic => diagnostic.Id).Order(StringComparer.Ordinal).ToArray()
58+
);
59+
await AssertEditCycleAsync(compilation, diagnostics).ConfigureAwait(false);
60+
}
61+
62+
internal static async Task AssertEditCycleAsync(
63+
CSharpCompilation compilation,
64+
IReadOnlyList<Diagnostic> expectedDiagnostics
65+
)
66+
{
67+
var analyzer = new ExhaustionAnalyzer();
68+
var originalTrees = compilation.SyntaxTrees.ToArray();
69+
var originalSources = originalTrees.Select(tree => tree.GetText().ToString()).ToArray();
70+
var originalDiagnostics = await compilation
71+
.WithAnalyzers([analyzer])
72+
.GetAnalyzerDiagnosticsAsync()
73+
.ConfigureAwait(false);
74+
AssertDiagnostics(compilation, expectedDiagnostics, originalDiagnostics);
75+
76+
var editedCompilation = compilation;
77+
var switchesEdited = 0;
78+
foreach (var tree in originalTrees)
79+
{
80+
var root = await tree.GetRootAsync().ConfigureAwait(false);
81+
var switches = root.DescendantNodes()
82+
.Where(node => node is SwitchExpressionSyntax or SwitchStatementSyntax)
83+
.ToArray();
84+
switchesEdited += switches.Length;
85+
var editedRoot = root.ReplaceNodes(
86+
switches,
87+
(original, _) =>
88+
original is SwitchExpressionSyntax
89+
? SyntaxFactory.ParseExpression("default!").WithTriviaFrom(original)
90+
: SyntaxFactory
91+
.ParseStatement("throw new System.InvalidOperationException();")
92+
.WithTriviaFrom(original)
93+
);
94+
var editedTree = tree.WithRootAndOptions(editedRoot, tree.Options);
95+
editedCompilation = editedCompilation.ReplaceSyntaxTree(tree, editedTree);
96+
}
97+
98+
Assert.IsTrue(
99+
switchesEdited > 0,
100+
"The edit cycle must actually replace at least one switch."
101+
);
102+
Assert.IsFalse(
103+
editedCompilation.SyntaxTrees.Any(tree =>
104+
tree.GetRoot()
105+
.DescendantNodes()
106+
.Any(node => node is SwitchExpressionSyntax or SwitchStatementSyntax)
107+
),
108+
"The edited document must contain no switches."
109+
);
110+
var editedDiagnostics = await editedCompilation
111+
.WithAnalyzers([analyzer])
112+
.GetAnalyzerDiagnosticsAsync()
113+
.ConfigureAwait(false);
114+
AssertDiagnostics(editedCompilation, [], editedDiagnostics);
115+
116+
var restoredCompilation = editedCompilation
117+
.RemoveAllSyntaxTrees()
118+
.AddSyntaxTrees(originalTrees);
119+
var restoredDiagnostics = await restoredCompilation
120+
.WithAnalyzers([analyzer])
121+
.GetAnalyzerDiagnosticsAsync()
122+
.ConfigureAwait(false);
123+
AssertDiagnostics(restoredCompilation, expectedDiagnostics, restoredDiagnostics);
124+
CollectionAssert.AreEqual(
125+
originalTrees,
126+
compilation.SyntaxTrees.ToArray(),
127+
"Editing must preserve the original compilation's trees."
128+
);
129+
CollectionAssert.AreEqual(
130+
originalSources,
131+
compilation.SyntaxTrees.Select(tree => tree.GetText().ToString()).ToArray(),
132+
"Editing must preserve the original source."
133+
);
134+
CollectionAssert.AreEqual(
135+
originalSources,
136+
restoredCompilation.SyntaxTrees.Select(tree => tree.GetText().ToString()).ToArray(),
137+
"Restoring must reproduce the original source exactly."
138+
);
139+
}
140+
141+
private static void AssertDiagnostics(
142+
CSharpCompilation compilation,
143+
IReadOnlyList<Diagnostic> expected,
144+
ImmutableArray<Diagnostic> actual
145+
)
146+
{
147+
Assert.IsFalse(
148+
actual.Any(diagnostic => diagnostic.Id == "AD0001"),
149+
string.Join(Environment.NewLine, actual)
150+
);
151+
Assert.AreEqual(
152+
expected.Count,
153+
actual.Length,
154+
"Each edit must produce exactly the expected analyzer diagnostics."
155+
);
156+
var orderedExpected = expected
157+
.OrderBy(diagnostic => diagnostic.Location.SourceSpan.Start)
158+
.ThenBy(diagnostic => diagnostic.Id, StringComparer.Ordinal)
159+
.ToArray();
160+
var orderedActual = actual
161+
.OrderBy(diagnostic => diagnostic.Location.SourceSpan.Start)
162+
.ThenBy(diagnostic => diagnostic.Id, StringComparer.Ordinal)
163+
.ToArray();
164+
for (var index = 0; index < orderedExpected.Length; index++)
165+
{
166+
var wanted = orderedExpected[index];
167+
var diagnostic = orderedActual[index];
168+
Assert.AreEqual(wanted.Id, diagnostic.Id);
169+
Assert.AreEqual(DiagnosticSeverity.Warning, diagnostic.Severity);
170+
Assert.AreEqual(wanted.Severity, diagnostic.Severity);
171+
Assert.AreEqual(wanted.WarningLevel, diagnostic.WarningLevel);
172+
Assert.AreEqual(wanted.IsSuppressed, diagnostic.IsSuppressed);
173+
Assert.AreEqual(
174+
wanted.GetMessage(CultureInfo.InvariantCulture),
175+
diagnostic.GetMessage(CultureInfo.InvariantCulture)
176+
);
177+
Assert.AreEqual(wanted.Location.SourceSpan, diagnostic.Location.SourceSpan);
178+
Assert.AreEqual(wanted.Location.GetLineSpan(), diagnostic.Location.GetLineSpan());
179+
Assert.IsTrue(diagnostic.Location.IsInSource);
180+
Assert.IsTrue(compilation.SyntaxTrees.Contains(diagnostic.Location.SourceTree));
181+
var diagnosedNode = diagnostic
182+
.Location.SourceTree.GetRoot()
183+
.FindNode(diagnostic.Location.SourceSpan);
184+
Assert.IsTrue(
185+
diagnosedNode is SwitchExpressionSyntax or SwitchStatementSyntax,
186+
"Diagnostics must identify an entire switch."
187+
);
188+
Assert.AreEqual(diagnosedNode.Span, diagnostic.Location.SourceSpan);
189+
Assert.IsTrue(diagnostic.Descriptor.IsEnabledByDefault);
190+
Assert.AreEqual("Design", diagnostic.Descriptor.Category);
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)