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,58 @@
using Microsoft.CodeAnalysis;
namespace ExtractMethod.Tooling;
/// <summary>Everything the extract-method report is built from: the resolved
/// selection, the raw classification buckets (em 02), the extract-first
/// candidates (em 03), the promoted signature line (em 03) and the v1
/// limitation notes. One record so the CLI and the tests compose EXACTLY the
/// same thing — the report cannot drift between them.</summary>
public sealed record ExtractionReport(
SelectionReport Selection,
ExtractionSuggestion Suggestion,
IReadOnlyList<ExtractFirstEntry> ExtractFirst,
SignatureSuggestion Signature,
IReadOnlyList<string> Notes);
/// <summary>
/// Composes the full extraction suggestion for a resolved selection: the em 02
/// data-flow buckets, the em 03 extract-first scan, the promoted signature and
/// the limitation notes. Throws the same clean exceptions as
/// <see cref="DataFlowClassifier.Classify"/> when analysis cannot bind.
/// </summary>
public static class ExtractionReporter
{
public static ExtractionReport Compose(SemanticModel model, SelectionReport selection)
{
var suggestion = DataFlowClassifier.Classify(model, selection);
var extractFirst = ExtractFirstScanner.Scan(model, selection);
var signature = SignatureBuilder.Build(model, selection, suggestion);
return new ExtractionReport(selection, suggestion, extractFirst, signature, BuildNotes(model, selection));
}
/// <summary>
/// The v1 limitation notes (parent decision #5 and the em 02 contract):
/// each note is printed only when its limitation actually applies to the
/// selection, so a clean selection gets a clean report.
/// </summary>
private static IReadOnlyList<string> BuildNotes(SemanticModel model, SelectionReport selection)
{
var notes = new List<string>();
if (ExtractFirstScanner.HasNonVariableAssignmentLeftSide(model, selection))
{
notes.Add(
"reads-only scan (decision 5): assignment left-hand sides are skipped, " +
"so writes to fields/indexers/properties are invisible to this report");
}
if (DataFlowClassifier.CompositeTrailingReturn(selection) is { } trailing)
{
notes.Add(
$"the selection ends with a composite return expression ({trailing.Expression!.ToFullString().Trim()}) — " +
"extract it into a local first to make the return value nameable");
}
return notes;
}
}