em 02: data-flow classification (params/returns/locals buckets)
- Tooling/DataFlowClassifier: buckets a resolved selection via Roslyn DataFlowAnalysis. Selection flow + tail flow (statements AFTER the selection in the same block) both use the two-argument AnalyzeDataFlow overload on the contiguous run; the em 01 spike verdict is pinned in tests — a synthetic BlockSyntax (parent decision #3) throws ArgumentException "statements not within tree" and would re-bind symbols, breaking the written-inside ∩ read-after identity match. - Buckets per parent spec as plain-string records: in-params = local + parameter reads (filtering the implicit `this`; fields/properties stay in the em 03 extract-first bucket), ByRef = reassigned inside via a non-declaration write (declaration initializers are not write-backs), returns = written ∧ read-after (tail, branch-insensitive over-approximation) + trailing `return X;` simple-name candidate, locals = written ∧ never read-after. - CLI now prints the raw bucket dump after the statement count line; classification failures exit 1 with a clean message (exit 0/1/2 contract preserved). - Tests: 6 new (in-param incl. `this` non-leak, multi-statement two-argument path, trailing-return, ref-vs-in differential, extract-first non-leak, synthetic-block dead path, single-statement one-argument path); shared DemoFixture loader; fixed pre-existing CS8602 in DemoFixtureTests. 36/36 green.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
using ExtractMethod.Tooling;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace BeforeAfter.Tests.ExtractMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for yak em 02: the data-flow classifier (buckets: params / returns /
|
||||
/// locals) over the checked-in Demo.cs fixture. Line anchors are the exact
|
||||
/// fixture lines pinned by em 01 (see Demo.cs header comment):
|
||||
/// ScoreReads body 56..58, Summarize body 68..76 (for-loop 69..72),
|
||||
/// Heaviest body 87..93.
|
||||
/// </summary>
|
||||
public class DataFlowClassificationTests
|
||||
{
|
||||
// ---------------------------------------------------------------------
|
||||
// in-param bucket (ScoreReads, full body 56..58).
|
||||
// ReadInside = {_seed→field, seed, score, bonus}; the classifier must
|
||||
// report the reads that are locals/params — seed/score/bonus — as in-
|
||||
// params, and must NOT leak `this` (the implicit this-read behind the
|
||||
// _seed field access; filtered via IParameterSymbol.IsThis) into params,
|
||||
// nor the _seed field itself (decision #6 keeps fields for em 03).
|
||||
// WrittenInside = {seed, score}; no tail, trailing return is an expression
|
||||
// (score + bonus) which v1 cannot name -> no returns, both written vars
|
||||
// are scratch locals.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void ScoreReads_full_body_reads_report_three_in_params_and_no_returns()
|
||||
{
|
||||
var suggestion = ResolveAndClassify(56, 58);
|
||||
|
||||
Assert.Equal(new[] { "bonus", "score", "seed" }, suggestion.Params.Select(p => p.Name));
|
||||
// None is reassigned inside (declaration initializers are not write-backs) -> no ref.
|
||||
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||
Assert.Empty(suggestion.Returns);
|
||||
// v1 reports the read locals ALSO as scratch locals (decision #6 over-
|
||||
// reports local reads); em 03 dedupes them into the signature.
|
||||
Assert.Equal(new[] { "score", "seed" }, suggestion.Locals);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// multi-statement range case (Summarize 68..72: LocalDeclarationStatement
|
||||
// + ForStatement): the resolution, the selection data flow AND the tail
|
||||
// data flow all go through the two-argument overload with Succeeded=true
|
||||
// (the synth-block trick from parent decision #3 is the DEAD path — see
|
||||
// Test for it below). Buckets: total written inside + read at line 74 in
|
||||
// the tail -> return; for-var i never read after -> local.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Summarize_multi_statement_range_flows_via_two_argument_overload_and_buckets_total_return_i_local()
|
||||
{
|
||||
var (tree, compilation) = DemoFixture.Load();
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||
Assert.True(report.Succeeded, report.Error);
|
||||
|
||||
var selectionFlow = DataFlowClassifier.AnalyzeSelectionFlow(model, report);
|
||||
Assert.True(selectionFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on a multi-statement selection");
|
||||
|
||||
var tailFlow = DataFlowClassifier.AnalyzeTailFlow(model, report);
|
||||
Assert.NotNull(tailFlow);
|
||||
Assert.True(tailFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on the (multi-statement) tail region");
|
||||
|
||||
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||
Assert.Equal(new[] { "total" }, suggestion.Returns);
|
||||
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||
|
||||
// limit is read in the for-header (68..72) — a plain by-value in-param.
|
||||
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||
Assert.False(limit.ByRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// return + ref-param buckets (Summarize, full body 68..76).
|
||||
// Returns: the tail is empty, so the tail-flow rule yields nothing; the
|
||||
// selection ends with `return message;` -> message is the trailing-return
|
||||
// candidate (parent spec). Locals: everything else written inside:
|
||||
// {i, scaled, total}.
|
||||
// Params (all ReadInside locals/params): the differential ref check —
|
||||
// total is REASSIGNED inside (total += i at line 71) -> ByRef; scaled and
|
||||
// limit are never reassigned (declaration write only) -> plain in-params.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Summarize_full_body_returns_trailing_message_and_flags_reassigned_total_as_ref()
|
||||
{
|
||||
var suggestion = ResolveAndClassify(68, 76);
|
||||
|
||||
Assert.Equal(new[] { "message" }, suggestion.Returns);
|
||||
Assert.Equal(new[] { "i", "scaled", "total" }, suggestion.Locals);
|
||||
|
||||
var total = Assert.Single(suggestion.Params.Where(p => p.Name == "total"));
|
||||
Assert.True(total.ByRef, "total is reassigned inside (total += i) -> the extraction owes a write-back");
|
||||
|
||||
var scaled = Assert.Single(suggestion.Params.Where(p => p.Name == "scaled"));
|
||||
Assert.False(scaled.ByRef, "scaled is only declared inside, never reassigned");
|
||||
|
||||
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||
Assert.False(limit.ByRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// return + extract-first isolation (Heaviest, full body 87..93).
|
||||
// Returns: `return best;` is the last statement -> best (trailing-return
|
||||
// rule again). Written inside but never read after: i -> local.
|
||||
// Params: reads are best/count/i/widgets; best and i are reassigned
|
||||
// inside -> ref. Nothing extract-first (widgets[i] indexer, Bigger
|
||||
// invocation — em 03 territory) may leak into any bucket: the params list
|
||||
// is exactly the four variable reads.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Heaviest_full_body_returns_best_locals_i_and_leaks_no_extract_first_names()
|
||||
{
|
||||
var suggestion = ResolveAndClassify(87, 93);
|
||||
|
||||
Assert.Equal(new[] { "best" }, suggestion.Returns);
|
||||
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||
Assert.Equal(new[] { "best", "count", "i", "widgets" }, suggestion.Params.Select(p => p.Name));
|
||||
Assert.Equal(new[] { true, false, true, false }, suggestion.Params.Select(p => p.ByRef));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// single-statement fallback (ScoreReads, line 58 alone: `return ...;`).
|
||||
// The one-argument AnalyzeDataFlow overload is the live path for a single
|
||||
// statement. The trailing return is `score + bonus` — an expression, not
|
||||
// a simple name — so v1 reports no return candidate (composite return
|
||||
// expressions are extract-first shape for em 03) and no locals.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Single_statement_selection_uses_one_argument_overload_and_composite_trailing_return_is_not_nameable()
|
||||
{
|
||||
var (tree, compilation) = DemoFixture.Load();
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
var report = SelectionResolver.Resolve(tree, 58, 58);
|
||||
Assert.True(report.Succeeded, report.Error);
|
||||
|
||||
Assert.True(DataFlowClassifier.AnalyzeSelectionFlow(model, report).Succeeded);
|
||||
|
||||
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||
Assert.Equal(new[] { "bonus", "score" }, suggestion.Params.Select(p => p.Name));
|
||||
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||
Assert.Empty(suggestion.Returns);
|
||||
Assert.Empty(suggestion.Locals);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// DEAD PATH (parent decision #3, pinned so the choice is self-explanatory
|
||||
// to future readers): a synthetic BlockSyntax re-parents tree nodes that
|
||||
// are not in the tree, and AnalyzeDataFlow refuses with
|
||||
// ArgumentException("statements not within tree"). That is why the live
|
||||
// path uses the two-argument overload (both endpoints are real tree
|
||||
// nodes) — which also keeps symbol identity across selection and tail
|
||||
// analyses, the prerequisite for the written-inside ∩ read-after
|
||||
// intersection of the return bucket.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Synthetic_block_data_flow_throws_not_within_tree_so_the_two_argument_overload_stays_live()
|
||||
{
|
||||
var (tree, compilation) = DemoFixture.Load();
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
var summarize = tree.GetRoot().DescendantNodes()
|
||||
.OfType<MethodDeclarationSyntax>()
|
||||
.Single(m => m.Identifier.ValueText == "Summarize");
|
||||
var statements = summarize.Body!.Statements;
|
||||
|
||||
// Same node instances, new (synthetic) parent — this is the shape
|
||||
// parent decision #3 proposed for multi-statement ranges and tails.
|
||||
var syntheticBlock = SyntaxFactory.Block(statements[2], statements[3], statements[4]);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => model.AnalyzeDataFlow(syntheticBlock));
|
||||
Assert.Contains("not within tree", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static ExtractionSuggestion ResolveAndClassify(int startLine, int endLine)
|
||||
{
|
||||
var (tree, compilation) = DemoFixture.Load();
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
var report = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||
Assert.True(report.Succeeded, report.Error);
|
||||
|
||||
return DataFlowClassifier.Classify(model, report);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user