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
@@ -21,19 +21,27 @@ namespace ExtractMethod.Tooling;
/// the precise v1 rule.</param>
public sealed record ParamSuggestion(string Name, string Type, bool ByRef);
/// <summary>One return candidate of the extracted method (v1: plain strings).</summary>
/// <param name="Name">Variable name as it appears in the source.</param>
/// <param name="Type">Type as a display string — the signature line of em 03
/// prints it as the extracted method's return type.</param>
public sealed record ReturnSuggestion(string Name, string Type);
/// <summary>The classified buckets of one selection (v1: symbol names as plain strings).</summary>
/// <param name="Params">Variables the selection READS and the new method therefore
/// receives: read locals + the enclosing method's parameters (parent decision #6).
/// A variable may also appear in <see cref="Locals"/> and/or <see cref="Returns"/> —
/// v1 reports per-bucket and em 03 dedupes into a coherent signature.</param>
/// v1 reports per-bucket; em 03's signature builder dedupes into a coherent
/// signature (a variable DECLARED inside the selection cannot be a parameter).</param>
/// <param name="Returns">Variables written inside AND read after the selection
/// (tail data flow), plus the value of a trailing <c>return X;</c> when X is a
/// simple name — they must flow out of the extraction.</param>
/// simple name — they must flow out of the extraction. Typed since em 03: the
/// signature line needs the return type, not just the name.</param>
/// <param name="Locals">Variables written inside and never read after: scratch
/// locals of the new method.</param>
public sealed record ExtractionSuggestion(
IReadOnlyList<ParamSuggestion> Params,
IReadOnlyList<string> Returns,
IReadOnlyList<ReturnSuggestion> Returns,
IReadOnlyList<string> Locals);
/// <summary>
@@ -146,20 +154,28 @@ public static class DataFlowClassifier
// ReadInside is ImmutableArray (not a set): fine for Contains lookups.
var readAfter = AnalyzeTailFlow(model, selection)?.ReadInside ?? ImmutableArray<ISymbol>.Empty;
var trailingReturn = TrailingReturnName(selection);
// ---- returns: written inside ∧ read after, plus trailing return X ----
var returnNames = writtenInside
// Typed since em 03 (the signature line needs the return type). The
// symbols come straight from the flow sets, so types are exact; the
// trailing-return candidate resolves its own identifier below.
var returnSymbols = writtenInside
.Where(v => readAfter.Contains(v))
.Select(v => v.Name)
.Concat(trailingReturn is { } name ? new[] { name } : Array.Empty<string>())
.Distinct(StringComparer.Ordinal)
.OrderBy(n => n, StringComparer.Ordinal)
.ToDictionary(v => v.Name, VariableTypeString, StringComparer.Ordinal);
var trailingReturn = TrailingReturnSuggestion(model, selection);
if (trailingReturn is { } candidate)
{
returnSymbols.TryAdd(candidate.Name, candidate.Type);
}
var returnSuggestions = returnSymbols
.Select(kv => new ReturnSuggestion(kv.Key, kv.Value))
.OrderBy(r => r.Name, StringComparer.Ordinal)
.ToList();
// ---- locals: written inside, never read after -> scratch locals ----
var localNames = writtenInside
.Where(v => !returnNames.Contains(v.Name, StringComparer.Ordinal))
.Where(v => !returnSymbols.ContainsKey(v.Name))
.Select(v => v.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
@@ -171,22 +187,22 @@ public static class DataFlowClassifier
.OrderBy(v => v.Name, StringComparer.Ordinal)
.Select(v => new ParamSuggestion(
v.Name,
ParamTypeString(v),
VariableTypeString(v),
IsReassignedInside(model, v, selection.Statements)))
.ToList();
return new ExtractionSuggestion(paramSuggestions, returnNames, localNames);
return new ExtractionSuggestion(paramSuggestions, returnSuggestions, localNames);
}
/// <summary>
/// Display string of the variable's type. ISymbol has no Type member;
/// only locals and parameters carry one (the params bucket is restricted
/// to exactly those kinds, hence the match).
/// Display string of a variable's type. ISymbol has no Type member; only
/// locals and parameters carry one. Used for the params bucket AND (since
/// em 03) the returns bucket — the signature line prints it.
/// </summary>
private static string ParamTypeString(ISymbol variable) => variable switch
private static string VariableTypeString(ISymbol variable) => variable switch
{
ILocalSymbol local => local.Type?.ToDisplayString() ?? "unknown",
IParameterSymbol parameter => parameter.Type?.ToDisplayString() ?? "unknown",
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
_ => "unknown",
};
@@ -264,14 +280,37 @@ public static class DataFlowClassifier
/// <summary>
/// Parent spec: "if the selection ends with `return X;`, X is the candidate."
/// Returns the simple-name candidate, or null when the returned expression
/// is not a simple name — an expression like `score + bonus` is not nameable
/// as a v1 string (composite return expressions are extract-first shape for
/// em 03). Empty return ('return;') has no candidate.
/// Returns the simple-name candidate with its type, or null when the
/// returned expression is not a simple name — an expression like
/// `score + bonus` is not nameable as a v1 string (composite return
/// expressions are extract-first shape for em 03: the report suggests
/// extracting it into a local first). Empty return ('return;') has no
/// candidate.
/// </summary>
private static string? TrailingReturnName(SelectionReport selection)
private static ReturnSuggestion? TrailingReturnSuggestion(SemanticModel model, SelectionReport selection)
{
var last = selection.Statements[^1];
return last is ReturnStatementSyntax { Expression: IdentifierNameSyntax name } ? name.Identifier.ValueText : null;
if (last is not ReturnStatementSyntax { Expression: IdentifierNameSyntax name })
{
return null;
}
var type = model.GetSymbolInfo(name).Symbol switch
{
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
_ => "unknown",
};
return new ReturnSuggestion(name.Identifier.ValueText, type);
}
/// <summary>
/// The trailing return statement when its expression is NOT a simple
/// name (and not absent) — the composite-return shape the em 03 report
/// flags as "extract a local first" (see <see cref="TrailingReturnSuggestion"/>).
/// </summary>
public static ReturnStatementSyntax? CompositeTrailingReturn(SelectionReport selection)
=> selection.Statements[^1] is ReturnStatementSyntax { Expression: not null and not IdentifierNameSyntax } ret
? ret
: null;
}