em 03: extract-first scan + full suggestion report
- ExtractFirstScanner: reads-only scan of non-local/param read expressions (member/field/property access, element access, invocation, cast), deduped by symbol+text with occurrence counts; invocations flagged (hoisting changes eval count); own-class field/property reads marked optional. Assignment LHS skipped whole (decision 5) — surfaced as a report note. - SignatureBuilder: promotes bucket overlaps into one coherent signature (declared-inside vars drop from params; declared-inside return candidates become the return; outside candidates already covered by their ref write-back stay ref params). Trailing composite returns stay void with a note suggesting an extract-variable first. - ExtractionReporter + ReportFormatter: single composition shared by CLI and tests; report = header, params (ref), returns, locals, extract-first (count + flags), notes, suggested-signature line last. - DataFlowClassifier: Returns now carry types (ReturnSuggestion) — the signature line needs them. - Displays: compact symbol/type formatting for the report. - Fixture: RepeatReads (dedupe ×2 + skipped field write), Casts (cast candidate) appended at the end so all pinned line numbers stay put.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using ExtractMethod.Tooling;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace BeforeAfter.Tests.ExtractMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for yak em 03: the extract-first scan over the checked-in Demo.cs
|
||||
/// fixture. Line anchors are the exact fixture lines pinned by em 01/02:
|
||||
/// ScoreReads body 56..58, Summarize body 68..76, Heaviest body 87..93,
|
||||
/// RepeatReads body 105..108, Casts body 116 (the em 03 additions at the
|
||||
/// end of the fixture; nothing before them may shift — see Demo.cs header).
|
||||
///
|
||||
/// Extract-first = read expressions that are NOT simple local/parameter
|
||||
/// references (parent bucket spec): member/field/property access, element
|
||||
/// access, invocations, casts — deduped by symbol + text, occurrence-
|
||||
/// counted, invocations flagged ("hoisting changes eval count"), own-class
|
||||
/// field/property access marked optional.
|
||||
/// </summary>
|
||||
public class ExtractFirstTests
|
||||
{
|
||||
// ---------------------------------------------------------------------
|
||||
// optional marking (own-class FIELD): ScoreReads reads `_seed` once;
|
||||
// `seed`, `score`, `bonus` are simple local/param names and must NOT
|
||||
// appear (decision #6 puts them in the params bucket instead).
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void ScoreReads_reports_own_class_field_once_and_optional()
|
||||
{
|
||||
var report = Compose(56, 58);
|
||||
|
||||
var entry = Assert.Single(report.ExtractFirst);
|
||||
Assert.Equal("_seed", entry.Text);
|
||||
Assert.Equal("Demo._seed", entry.SymbolDisplay);
|
||||
Assert.Equal(1, entry.Occurrences);
|
||||
Assert.False(entry.IsInvocation);
|
||||
Assert.True(entry.Optional, "own-class field reads are optional to hoist");
|
||||
Assert.Equal("field", entry.OwnMemberKind);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// optional marking (own-class PROPERTY): Summarize reads `Scale` once
|
||||
// (line 74). The binary expressions around it (`total * Scale`,
|
||||
// `"sum=" + scaled`) are composites of locals/params and literals —
|
||||
// exactly the shapes the parent spec does NOT list, so they stay out.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Summarize_reports_own_class_property_once_and_optional()
|
||||
{
|
||||
var report = Compose(68, 76);
|
||||
|
||||
var entry = Assert.Single(report.ExtractFirst);
|
||||
Assert.Equal("Scale", entry.Text);
|
||||
Assert.Equal("Demo.Scale", entry.SymbolDisplay);
|
||||
Assert.Equal(1, entry.Occurrences);
|
||||
Assert.False(entry.IsInvocation);
|
||||
Assert.True(entry.Optional, "own-class property reads are optional to hoist");
|
||||
Assert.Equal("property", entry.OwnMemberKind);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// invocation flag + element access (Heaviest, full body 87..93):
|
||||
// `widgets[0]` and `widgets[i]` are two DIFFERENT indexer reads (same
|
||||
// indexer symbol, different text -> separate entries); the call
|
||||
// `best.Bigger(widgets[i])` is flagged because hoisting it changes how
|
||||
// often it is evaluated. The callee `best.Bigger` must NOT be reported
|
||||
// separately (the invocation covers it), and `best`/`i`/`widgets`/
|
||||
// `count` are simple local/param names — invisible to this bucket.
|
||||
// None of Heaviest's candidates is own-class (Widget is a nested record,
|
||||
// the indexer belongs to List<T>) -> nothing optional here.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Heaviest_flags_the_invocation_and_lists_both_indexer_reads()
|
||||
{
|
||||
var report = Compose(87, 93);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "widgets[0]", "best.Bigger(widgets[i])", "widgets[i]" },
|
||||
report.ExtractFirst.Select(e => e.Text));
|
||||
Assert.All(report.ExtractFirst, e => Assert.Equal(1, e.Occurrences));
|
||||
Assert.All(report.ExtractFirst, e => Assert.False(e.Optional)); // none of Heaviest's reads is own-class
|
||||
|
||||
var invocation = Assert.Single(report.ExtractFirst, e => e.IsInvocation);
|
||||
Assert.Equal("best.Bigger(widgets[i])", invocation.Text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// dedupe + occurrence count (RepeatReads, body 105..108): `_seed` is
|
||||
// read TWICE (lines 105, 106) and must dedupe to ONE entry with
|
||||
// Occurrences == 2. The field WRITE `_seed = a;` (line 107) is a
|
||||
// non-variable assignment LHS — the reads-only scan skips it, so it
|
||||
// neither adds an occurrence nor changes the count (decision 5), and
|
||||
// the report must carry the decision-5 limitation note.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void RepeatReads_dedupes_the_field_read_to_count_two_and_skips_the_field_write()
|
||||
{
|
||||
var report = Compose(105, 108);
|
||||
|
||||
var entry = Assert.Single(report.ExtractFirst);
|
||||
Assert.Equal("_seed", entry.Text);
|
||||
Assert.Equal(2, entry.Occurrences);
|
||||
Assert.True(entry.Optional);
|
||||
Assert.Equal("field", entry.OwnMemberKind);
|
||||
|
||||
// Two notes apply to this selection: the decision-5 write limitation
|
||||
// AND the composite trailing return (`return a + b;`).
|
||||
Assert.Equal(2, report.Notes.Count);
|
||||
Assert.Contains(report.Notes, n => n.Contains("assignment left-hand sides", StringComparison.Ordinal));
|
||||
Assert.Contains(report.Notes, n => n.Contains("composite return expression (a + b)", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// cast (Casts, body 116): `(double)total` is an extract-first candidate
|
||||
// (parent spec lists casts). A conversion is not a symbol, so the entry
|
||||
// keys on its text alone ("unbound" display) and carries no flags.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void Casts_reports_the_cast_expression_and_the_field_read()
|
||||
{
|
||||
var report = Compose(116, 116);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "(double)total", "_seed" },
|
||||
report.ExtractFirst.Select(e => e.Text));
|
||||
|
||||
var cast = report.ExtractFirst[0];
|
||||
Assert.Equal(1, cast.Occurrences);
|
||||
Assert.False(cast.IsInvocation);
|
||||
Assert.False(cast.Optional);
|
||||
Assert.Equal("<unbound>", cast.SymbolDisplay);
|
||||
}
|
||||
|
||||
private static ExtractionReport Compose(int startLine, int endLine)
|
||||
{
|
||||
var (tree, compilation) = DemoFixture.Load();
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||
Assert.True(resolved.Succeeded, resolved.Error);
|
||||
|
||||
return ExtractionReporter.Compose(model, resolved);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user