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:
2026-09-12 18:26:20 +01:00
parent 5d02c1aabf
commit 49a5633a7d
11 changed files with 985 additions and 68 deletions
@@ -0,0 +1,106 @@
using ExtractMethod.Tooling;
using Microsoft.CodeAnalysis;
namespace BeforeAfter.Tests.ExtractMethod;
/// <summary>
/// Tests for yak em 03: the signature promotion (SignatureBuilder) and the
/// formatted report (ReportFormatter) over the checked-in Demo.cs fixture.
/// Line anchors are the exact fixture lines pinned by em 01/02: ScoreReads
/// body 56..58, Summarize 68..76 (partial selection 68..72), Heaviest 87..93.
/// </summary>
public class ReportTests
{
// ---------------------------------------------------------------------
// Signature promotion, the case em 02's comment promises: in Summarize
// lines 68..72, `total` sits in params[ref] AND returns (declared inside,
// reassigned inside, read after), and `i` sits in params AND locals (the
// for-header declares it inside). Promotion: both drop out of the params,
// total becomes the plain return value -> `int Extract(int limit)`.
// ---------------------------------------------------------------------
[Fact]
public void Signature_for_Summarize_selection_promotes_declared_inside_variables()
{
var (report, formatted) = Compose(68, 72);
Assert.Equal(new[] { "limit" }, report.Signature.Params.Select(p => p.Name));
Assert.Equal("int", report.Signature.ReturnType);
Assert.Contains("suggested signature: int Extract(int limit)", formatted);
}
// ---------------------------------------------------------------------
// Heaviest full body: `best` (declared inside, ref in the raw params,
// trailing return candidate) promotes to the return; the for-var `i`
// drops from the params; widgets/count stay as plain in-params.
// ---------------------------------------------------------------------
[Fact]
public void Signature_for_Heaviest_returns_the_trailing_candidate_and_drops_declared_inside_params()
{
var (_, formatted) = Compose(87, 93);
// Parameter order follows the classifier's ordinal-by-name order —
// the same order the params bucket above prints (em 02 determinism).
Assert.Contains("suggested signature: Widget Extract(int count, List<Widget> widgets)", formatted);
}
// ---------------------------------------------------------------------
// No nameable return -> void (ScoreReads ends with `return score + bonus;`,
// a composite expression v1 cannot name — the report notes this); locals
// declared inside (seed, score) drop from the params; only the enclosing
// method's own parameter survives.
// ---------------------------------------------------------------------
[Fact]
public void Signature_for_ScoreReads_is_void_and_carries_only_the_enclosing_parameter()
{
var (report, formatted) = Compose(56, 58);
Assert.Equal(new[] { "bonus" }, report.Signature.Params.Select(p => p.Name));
Assert.Equal("void", report.Signature.ReturnType);
Assert.Contains("suggested signature: void Extract(int bonus)", formatted);
// The void-ness has an explanation in the report: the composite
// trailing return is not nameable in v1.
Assert.Contains(report.Notes, n => n.Contains("composite return expression (score + bonus)", StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// The report the CLI prints, pinned EXACTLY for one fixture selection
// (acceptance: "CLI report on the fixture matches the tests"). Heaviest
// full body is the richest case: raw buckets (params include the
// declared-inside best/i — the promotion happens only in the signature),
// extract-first with the invocation flag, no notes, signature last.
// ---------------------------------------------------------------------
[Fact]
public void Formatted_report_for_Heaviest_matches_the_CLI_output_exactly()
{
var (_, formatted) = Compose(87, 93);
var expected =
"3 statement(s) selected, lines 87..93 in Heaviest(): LocalDeclarationStatement, ForStatement, ReturnStatement\n" +
"params: best (Widget) [ref]\n" +
"params: count (int) [in]\n" +
"params: i (int) [ref]\n" +
"params: widgets (List<Widget>) [in]\n" +
"returns: best (Widget)\n" +
"locals: i\n" +
"extract-first:\n" +
" - widgets[0] — List<Widget>.this[int] ×1\n" +
" - best.Bigger(widgets[i]) — Widget.Bigger(Widget) ×1 [hoisting changes eval count]\n" +
" - widgets[i] — List<Widget>.this[int] ×1\n" +
"suggested signature: Widget Extract(int count, List<Widget> widgets)\n";
Assert.Equal(expected, formatted);
}
private static (ExtractionReport Report, string Formatted) 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);
var report = ExtractionReporter.Compose(model, resolved);
return (report, ReportFormatter.Format(report));
}
}