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,80 @@
using System.Text;
namespace ExtractMethod.Tooling;
/// <summary>
/// Renders an <see cref="ExtractionReport"/> as the human-readable extract-
/// method suggestion report (parent spec): params (with ref), return
/// candidates, locals, extract-first candidates (deduped, with occurrence
/// counts and flags), the limitation notes, and the suggested signature line
/// LAST. Pure string shaping — no Roslyn — so tests can pin the exact output
/// the CLI prints.
/// </summary>
public static class ReportFormatter
{
public static string Format(ExtractionReport report)
{
var sb = new StringBuilder();
var selection = report.Selection;
var suggestion = report.Suggestion;
// Header (same wording the CLI printed since em 01).
sb.AppendLine(
$"{selection.Count} statement(s) selected, lines {selection.StartLine}..{selection.EndLine} " +
$"in {selection.Method?.Identifier.ValueText}(): " +
string.Join(", ", selection.Kinds));
AppendBucket(sb, "params", suggestion.Params.Select(p =>
$"{p.Name} ({p.Type}){(p.ByRef ? " [ref]" : " [in]")}"));
AppendBucket(sb, "returns", suggestion.Returns.Select(r => $"{r.Name} ({r.Type})"));
AppendBucket(sb, "locals", suggestion.Locals);
if (report.ExtractFirst.Count == 0)
{
sb.AppendLine("extract-first: (none)");
}
else
{
sb.AppendLine("extract-first:");
foreach (var entry in report.ExtractFirst)
{
// Flags accumulate; occurrence count is always shown because
// "count occurrences" is the point of the dedupe.
var flags = new List<string>();
if (entry.IsInvocation)
{
flags.Add("hoisting changes eval count");
}
if (entry.Optional)
{
flags.Add($"optional: own-class {entry.OwnMemberKind}");
}
var flagText = flags.Count > 0 ? $" [{string.Join("; ", flags)}]" : string.Empty;
sb.AppendLine($" - {entry.Text} — {entry.SymbolDisplay} ×{entry.Occurrences}{flagText}");
}
}
foreach (var note in report.Notes)
{
sb.AppendLine($"note: {note}");
}
var signature = report.Signature;
var parameterList = string.Join(", ", signature.Params.Select(p => $"{(p.ByRef ? "ref " : "")}{p.Type} {p.Name}"));
sb.AppendLine($"suggested signature: {signature.ReturnType} {signature.MethodName}({parameterList})");
return sb.ToString();
}
private static void AppendBucket(StringBuilder sb, string name, IEnumerable<string> items)
{
var enumerated = items.ToList();
sb.AppendLine(enumerated.Count == 0 ? $"{name}: (none)" : $"{name}: {enumerated[0]}");
for (var i = 1; i < enumerated.Count; i++)
{
sb.AppendLine($"{name}: {enumerated[i]}");
}
}
}