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
@@ -63,7 +63,8 @@ public class DataFlowClassificationTests
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[] { "total" }, suggestion.Returns.Select(r => r.Name));
Assert.Equal(new[] { "int" }, suggestion.Returns.Select(r => r.Type));
Assert.Equal(new[] { "i" }, suggestion.Locals);
// limit is read in the for-header (68..72) — a plain by-value in-param.
@@ -86,7 +87,7 @@ public class DataFlowClassificationTests
{
var suggestion = ResolveAndClassify(68, 76);
Assert.Equal(new[] { "message" }, suggestion.Returns);
Assert.Equal(new[] { "message" }, suggestion.Returns.Select(r => r.Name));
Assert.Equal(new[] { "i", "scaled", "total" }, suggestion.Locals);
var total = Assert.Single(suggestion.Params.Where(p => p.Name == "total"));
@@ -113,7 +114,7 @@ public class DataFlowClassificationTests
{
var suggestion = ResolveAndClassify(87, 93);
Assert.Equal(new[] { "best" }, suggestion.Returns);
Assert.Equal(new[] { "best" }, suggestion.Returns.Select(r => r.Name));
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));
@@ -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);
}
}
@@ -92,4 +92,27 @@ public class Demo
return best;
}
/// <summary>
/// Bucket (em 03): the field read <c>_seed</c> appears TWICE — the
/// extract-first scan must dedupe it to one entry with an occurrence
/// count of 2. The field WRITE below (<c>_seed = a;</c>) is a
/// non-variable assignment left-hand side: the reads-only scan skips it
/// (decision 5) and the report notes the limitation.
/// </summary>
public int RepeatReads(int n)
{
int a = _seed + n;
int b = _seed * n;
_seed = a;
return a + b;
}
/// <summary>Bucket (em 03): a cast is an extract-first candidate too
/// (parent spec lists casts); its bound symbol is null — conversions are
/// not symbols — so the entry keys on its text alone.</summary>
public double Casts(int total)
{
return (double)total / _seed;
}
}
@@ -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));
}
}