Files
SVSimServer/tools/engine-port/ClosureAnalyzer/Program.cs

1171 lines
56 KiB
C#

using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.MSBuild;
namespace ClosureAnalyzer;
public static class Program
{
// Roots — see POSTMORTEM-2026-06-28-pass3-attempt.md §"What the right tool looks like".
// Fully-qualified metadata names so GetTypeByMetadataName can find them directly.
private static readonly string[] HardRoots =
{
"SVSim.BattleNode.Sessions.Engine.SessionBattleEngine",
"SVSim.BattleEngine.Rng.HeadlessBattleMgr",
"SVSim.BattleEngine.Rng.HeadlessNetworkBattleMgr",
"Wizard.Battle.Mulligan.MulliganMgrBase",
};
private static readonly HashSet<string> NUnitAttributeNames = new(StringComparer.Ordinal)
{
"TestAttribute", "TestCaseAttribute", "TestCaseSourceAttribute",
"TestFixtureAttribute", "TestFixtureSourceAttribute", "TheoryAttribute",
"OneTimeSetUpAttribute", "OneTimeTearDownAttribute", "SetUpAttribute",
"TearDownAttribute", "SetUpFixtureAttribute",
};
private static readonly HashSet<string> TrackedAssemblies = new(StringComparer.Ordinal)
{
"SVSim.BattleEngine", "SVSim.BattleNode", "SVSim.BattleEngine.Tests",
};
// Only types from THIS assembly are deletion candidates. Everything else is either
// a downstream consumer (BattleNode/Tests) or external (System/NuGet).
private const string CandidateAssembly = "SVSim.BattleEngine";
public static async Task<int> Main(string[] args)
{
MSBuildLocator.RegisterDefaults();
bool trimMode = args.Contains("--trim");
var positional = args.Where(a => !a.StartsWith("--")).ToArray();
var repoRoot = FindRepoRoot();
var slnPath = Path.Combine(repoRoot, "DCGEngine.sln");
var outputPath = positional.Length > 0
? positional[0]
: Path.Combine(repoRoot, "..", "..", "data_dumps", "reports", "engine-cleanup", "provably-unreachable.tsv");
outputPath = Path.GetFullPath(outputPath);
if (trimMode)
Console.Error.WriteLine("[analyzer] TRIM MODE: will rewrite Shim/Generated/*.g.cs after analysis");
Console.Error.WriteLine($"[analyzer] solution: {slnPath}");
Console.Error.WriteLine($"[analyzer] output: {outputPath}");
using var workspace = MSBuildWorkspace.Create();
workspace.WorkspaceFailed += (_, e) =>
{
if (e.Diagnostic.Kind == WorkspaceDiagnosticKind.Failure)
Console.Error.WriteLine($"[workspace] FAIL: {e.Diagnostic.Message}");
};
var solution = await workspace.OpenSolutionAsync(slnPath);
var engineProject = solution.Projects.First(p => p.Name == "SVSim.BattleEngine");
var nodeProject = solution.Projects.First(p => p.Name == "SVSim.BattleNode");
var testsProject = solution.Projects.First(p => p.Name == "SVSim.BattleEngine.Tests");
Console.Error.WriteLine("[analyzer] compiling SVSim.BattleEngine...");
var engineComp = await engineProject.GetCompilationAsync()
?? throw new InvalidOperationException("engine compilation null");
Console.Error.WriteLine("[analyzer] compiling SVSim.BattleNode...");
var nodeComp = await nodeProject.GetCompilationAsync()
?? throw new InvalidOperationException("node compilation null");
Console.Error.WriteLine("[analyzer] compiling SVSim.BattleEngine.Tests...");
var testsComp = await testsProject.GetCompilationAsync()
?? throw new InvalidOperationException("tests compilation null");
// MSBuildWorkspace sometimes loads BattleNode without the extern-alias project
// reference to BattleEngine (an upstream NuGet audit warning during restore can
// cause it to bail out before honoring ProjectReference Aliases — guarded against
// by NU1903 suppression in those csprojs, but defensive in case other warnings
// crop up). Detect a missing cross-project lookup and patch the compilation by
// hand so semantic queries through `engine::` resolve correctly.
if (nodeComp.GetTypeByMetadataName("NetworkUserInfoData") is null)
{
var engineRef = engineComp.ToMetadataReference(
aliases: System.Collections.Immutable.ImmutableArray.Create("engine"));
nodeComp = nodeComp.AddReferences(engineRef);
Console.Error.WriteLine("[fixup] nodeComp missing engine reference — re-added with alias=engine");
}
if (testsComp.GetTypeByMetadataName("NetworkUserInfoData") is null)
{
var engineRef = engineComp.ToMetadataReference(
aliases: System.Collections.Immutable.ImmutableArray.Create("global", "engine"));
testsComp = testsComp.AddReferences(engineRef);
Console.Error.WriteLine("[fixup] testsComp missing engine reference — re-added with aliases=global,engine");
}
var compsByTree = new Dictionary<SyntaxTree, Compilation>();
foreach (var t in engineComp.SyntaxTrees) compsByTree[t] = engineComp;
foreach (var t in nodeComp.SyntaxTrees) compsByTree[t] = nodeComp;
foreach (var t in testsComp.SyntaxTrees) compsByTree[t] = testsComp;
// Collect roots.
var roots = new List<INamedTypeSymbol>();
foreach (var name in HardRoots)
{
var sym =
engineComp.GetTypeByMetadataName(name) ??
nodeComp.GetTypeByMetadataName(name) ??
testsComp.GetTypeByMetadataName(name);
if (sym is null)
throw new InvalidOperationException($"root not found in any tracked compilation: {name}");
roots.Add(sym);
Console.Error.WriteLine($"[root] {name}");
}
int testRoots = 0;
foreach (var t in EnumerateTypes(testsComp.GlobalNamespace))
{
if (HasNUnitAttribute(t))
{
roots.Add(t);
testRoots++;
}
}
Console.Error.WriteLine($"[root] NUnit-rooted test types: {testRoots}");
// Every type defined in SVSim.BattleNode is a root. BattleNode is the consumer of
// BattleEngine; its types are called from the ASP.NET host / DI container / hub
// entrypoints that this static walk can't see. Walking them all is cheap (BattleNode
// is ~110 types) and ensures every BattleEngine API call site is detected.
int nodeRoots = 0;
foreach (var t in EnumerateTypes(nodeComp.GlobalNamespace))
{
if (t.ContainingAssembly?.Name == "SVSim.BattleNode")
{
roots.Add(t);
nodeRoots++;
}
}
Console.Error.WriteLine($"[root] BattleNode types: {nodeRoots}");
// BFS the closure. `seen` is keyed by fully-qualified type name (string) for the
// same reason reachedMembers is — cross-compilation lookups via the engine alias
// produce INamedTypeSymbols whose SymbolEqualityComparer-identity doesn't match
// engineComp's direct view (so NullDetailPanelControl, reached through
// SessionBattleEngine's `using NullDetailPanelControl = engine::NullDetailPanelControl`,
// wasn't recognized as reachable in engineComp's later enumeration pass).
var queue = new Queue<INamedTypeSymbol>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var seenTypeSymbols = new List<INamedTypeSymbol>(); // for later iteration (override/iface propagation)
string TypeKey(INamedTypeSymbol t) =>
t.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Method-level: every IMethodSymbol / IPropertySymbol / IFieldSymbol / IEventSymbol
// referenced by reached code. Tracked by SignatureKey rather than ISymbol identity
// because cross-compilation symbol resolution (BattleNode resolving a BattleEngine
// method via an extern-alias project reference) produces an IMethodSymbol whose
// SymbolEqualityComparer-identity does NOT match engineComp's direct view of the
// same method, even though they refer to the same assembly. Display-string keys
// dodge that entirely.
var reachedMembers = new HashSet<string>(StringComparer.Ordinal);
// String literals seen in reachable code — used as a defensive fallback for
// reflection-driven dispatch (typeof(T).GetMethod("X"), Activator.CreateInstance,
// SendMessage by name, etc.). Any member of any reachable type whose Name appears
// here is kept alive even if the static walk says dead.
var literalStrings = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in roots) Enqueue(r, queue);
long popped = 0;
while (queue.Count > 0)
{
var t = queue.Dequeue();
var orig = (INamedTypeSymbol)t.OriginalDefinition;
if (!seen.Add(TypeKey(orig))) continue;
seenTypeSymbols.Add(orig);
popped++;
if (popped % 200 == 0)
Console.Error.WriteLine($"[bfs] popped={popped} queue={queue.Count} seen={seen.Count}");
var asmName = orig.ContainingAssembly?.Name;
if (asmName is null || !TrackedAssemblies.Contains(asmName))
continue;
// Base + interfaces + containing.
if (orig.BaseType is not null) Enqueue(orig.BaseType, queue);
foreach (var iface in orig.Interfaces) Enqueue(iface, queue);
if (orig.ContainingType is not null) Enqueue(orig.ContainingType, queue);
// Type's own attributes.
foreach (var attr in orig.GetAttributes())
if (attr.AttributeClass is not null)
Enqueue(attr.AttributeClass, queue);
// Generic constraints on the type itself.
foreach (var tp in orig.TypeParameters)
foreach (var con in tp.ConstraintTypes)
EnqueueAny(con, queue);
// Members.
foreach (var member in orig.GetMembers())
{
foreach (var attr in member.GetAttributes())
if (attr.AttributeClass is not null)
Enqueue(attr.AttributeClass, queue);
switch (member)
{
case IFieldSymbol f:
EnqueueAny(f.Type, queue);
break;
case IPropertySymbol p:
EnqueueAny(p.Type, queue);
foreach (var par in p.Parameters)
{
EnqueueAny(par.Type, queue);
foreach (var pa in par.GetAttributes())
if (pa.AttributeClass is not null) Enqueue(pa.AttributeClass, queue);
}
break;
case IEventSymbol e:
EnqueueAny(e.Type, queue);
break;
case IMethodSymbol m:
EnqueueAny(m.ReturnType, queue);
foreach (var par in m.Parameters)
{
EnqueueAny(par.Type, queue);
foreach (var pa in par.GetAttributes())
if (pa.AttributeClass is not null) Enqueue(pa.AttributeClass, queue);
}
foreach (var tp in m.TypeParameters)
foreach (var con in tp.ConstraintTypes)
EnqueueAny(con, queue);
foreach (var iimpl in m.ExplicitInterfaceImplementations)
Enqueue(iimpl.ContainingType, queue);
break;
case INamedTypeSymbol nested:
Enqueue(nested, queue);
break;
}
}
// Walk syntax of every declaration to catch method-body / initializer / typeof refs.
foreach (var sref in orig.DeclaringSyntaxReferences)
{
var node = sref.GetSyntax();
var tree = node.SyntaxTree;
if (!compsByTree.TryGetValue(tree, out var comp)) continue;
var model = comp.GetSemanticModel(tree);
// File-scope using directives (`using Foo = engine::Foo;`) live on the
// CompilationUnit, not inside the type. Walk them so types referenced only
// via aliased usings get into the closure. Without this, the cascade
// deletes types like NullDetailPanelControl that SessionBattleEngine
// references purely through the alias.
//
// GetTypeInfo on the name-syntax inside a using directive returns void
// because it's not an expression context — go through GetAliasInfo
// (for `using X = T;`) and GetSymbolInfo (for plain `using NS;`) instead.
if (tree.GetRoot() is Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax cu)
{
foreach (var usingDir in cu.Usings)
{
// Alias form: `using X = Y;` — GetDeclaredSymbol on the directive
// resolves to an IAliasSymbol whose Target is the real type.
if (usingDir.Alias is not null)
{
if (model.GetDeclaredSymbol(usingDir) is IAliasSymbol alias)
{
AddSymbol(alias.Target, queue);
RecordMember(alias.Target, reachedMembers);
}
}
// Plain form: `using NS;` or `using static T;` — resolve the
// Name itself.
var nameSymbol = model.GetSymbolInfo(usingDir.Name!).Symbol;
AddSymbol(nameSymbol, queue);
RecordMember(nameSymbol, reachedMembers);
// (No longer: enqueue every type in the namespace.) That rule was
// a workaround for dangling `using` directives after every type
// in a namespace got trimmed. With the engine reduced to its
// converged state, keeping types alive just because a file
// imports their namespace is wrong — the third-party SDK shims
// (Facebook.Unity, Steamworks, ZXing, BestHTTP, etc.) only stay
// because of stale `using` directives in engine files that never
// actually reference any type from them. The new dangling-using
// sweep at the end of trim handles the cleanup automatically.
}
}
foreach (var descendant in node.DescendantNodes())
{
var ti = model.GetTypeInfo(descendant);
EnqueueAny(ti.Type, queue);
EnqueueAny(ti.ConvertedType, queue);
var si = model.GetSymbolInfo(descendant);
AddSymbol(si.Symbol, queue);
RecordMember(si.Symbol, reachedMembers);
foreach (var cand in si.CandidateSymbols)
{
AddSymbol(cand, queue);
RecordMember(cand, reachedMembers);
}
// String literals for reflection-target fallback.
if (descendant is Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax lit
&& lit.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralExpression))
{
var s = lit.Token.ValueText;
if (!string.IsNullOrEmpty(s) && s.Length < 200)
literalStrings.Add(s);
}
}
}
}
Console.Error.WriteLine($"[bfs] closure size: {seen.Count}");
Console.Error.WriteLine($"[bfs] string literals seen: {literalStrings.Count}");
Console.Error.WriteLine($"[bfs] initial reached members: {reachedMembers.Count}");
// Propagate virtual / interface dispatch within the reachable type set.
// If a method M is reached and any subclass override M' lives on a reachable
// type, M' is also reached. Same for interface impls. Iterate until fixpoint.
var reachedTypeList = seenTypeSymbols
.Where(t => t.ContainingAssembly is not null && TrackedAssemblies.Contains(t.ContainingAssembly.Name))
.ToList();
// Unconditional protection pass — required for COMPILATION, not just reachability:
// 1. Any override of an abstract method must stay.
// 2. Any implementation of an interface member must stay if the interface is in the
// closure.
// 3. Any non-abstract override is conservatively kept — the override's body might
// differ from the base (and confirming equality across the whole vendored
// decompile is not feasible). Cheap to keep, expensive to get wrong.
// 4. Every member of a reachable INTERFACE stays. Explicit interface impls
// reference these members by their fully-qualified name; deleting an interface
// member while its explicit impl survives produces "is not found among members
// of the interface" (CS0539).
foreach (var t in reachedTypeList)
{
if (t.TypeKind == TypeKind.Interface)
{
foreach (var member in t.GetMembers())
{
reachedMembers.Add(SignatureKey(member));
if (member is IPropertySymbol ip)
{
if (ip.GetMethod is not null) reachedMembers.Add(SignatureKey(ip.GetMethod));
if (ip.SetMethod is not null) reachedMembers.Add(SignatureKey(ip.SetMethod));
}
if (member is IEventSymbol ie)
{
if (ie.AddMethod is not null) reachedMembers.Add(SignatureKey(ie.AddMethod));
if (ie.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ie.RemoveMethod));
}
}
}
}
foreach (var t in reachedTypeList)
{
foreach (var member in t.GetMembers())
{
// Constructors of reachable types always stay. A derived ctor that's removed
// breaks compilation when the base type lacks a parameterless ctor (CS7036),
// and detecting that gates on chasing the base chain — far cheaper to keep
// every ctor than to compute when it's safe to drop.
//
// Static constructors are invoked by the runtime on first access; static
// analysis sees no caller and would mark them dead, but their bodies often
// populate static fields (`_str2Keyword`, etc.) that other reachable code
// reads — removing them produces NullReferenceException at runtime.
if (member is IMethodSymbol mc &&
(mc.MethodKind == MethodKind.Constructor || mc.MethodKind == MethodKind.StaticConstructor))
{
reachedMembers.Add(SignatureKey(mc));
}
if (member is IMethodSymbol m && m.IsOverride)
{
reachedMembers.Add(SignatureKey(m));
}
if (member is IPropertySymbol p && p.IsOverride)
{
reachedMembers.Add(SignatureKey(p));
if (p.GetMethod is not null) reachedMembers.Add(SignatureKey(p.GetMethod));
if (p.SetMethod is not null) reachedMembers.Add(SignatureKey(p.SetMethod));
}
if (member is IEventSymbol e && e.IsOverride)
{
reachedMembers.Add(SignatureKey(e));
}
// User-defined operators come in mandatory pairs (==/!=, </>, <=/>=) per
// CS0216. Keeping one without the other is a build error. Cheap to keep
// all of them.
if (member is IMethodSymbol mop &&
(mop.MethodKind == MethodKind.UserDefinedOperator || mop.MethodKind == MethodKind.Conversion))
{
reachedMembers.Add(SignatureKey(mop));
}
// Explicit interface implementations always stay. Belt-and-suspenders on
// top of the AllInterfaces walk below — FindImplementationForInterfaceMember
// can miss in corner cases (e.g. IList vs ICollection both declaring Clear
// with overlapping resolution paths).
if (member is IMethodSymbol mex && mex.ExplicitInterfaceImplementations.Length > 0)
{
reachedMembers.Add(SignatureKey(mex));
}
if (member is IPropertySymbol pex && pex.ExplicitInterfaceImplementations.Length > 0)
{
reachedMembers.Add(SignatureKey(pex));
if (pex.GetMethod is not null) reachedMembers.Add(SignatureKey(pex.GetMethod));
if (pex.SetMethod is not null) reachedMembers.Add(SignatureKey(pex.SetMethod));
}
if (member is IEventSymbol eex && eex.ExplicitInterfaceImplementations.Length > 0)
{
reachedMembers.Add(SignatureKey(eex));
}
}
foreach (var iface in t.AllInterfaces)
{
// EVERY implemented interface — including external (System.Collections.ICollection,
// IComparer<T>, etc.) — needs its impls kept. The interface contract must be
// satisfied for compilation; CS0535 doesn't care whether the interface is in our
// assemblies or in mscorlib.
foreach (var ifaceM in iface.GetMembers())
{
var impl = t.FindImplementationForInterfaceMember(ifaceM);
if (impl is null) continue;
reachedMembers.Add(SignatureKey(impl));
if (impl is IPropertySymbol ip)
{
if (ip.GetMethod is not null) reachedMembers.Add(SignatureKey(ip.GetMethod));
if (ip.SetMethod is not null) reachedMembers.Add(SignatureKey(ip.SetMethod));
}
if (impl is IEventSymbol ie)
{
if (ie.AddMethod is not null) reachedMembers.Add(SignatureKey(ie.AddMethod));
if (ie.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ie.RemoveMethod));
}
}
}
}
Console.Error.WriteLine($"[propagate] members after unconditional override+impl pass: {reachedMembers.Count}");
// Chase override → overridden base. Required for compilation: a kept override
// needs its base method to still exist (CS0115).
bool basesChanged = true;
while (basesChanged)
{
basesChanged = false;
foreach (var t in reachedTypeList)
{
foreach (var member in t.GetMembers())
{
if (member is IMethodSymbol m && m.IsOverride && reachedMembers.Contains(SignatureKey(m)))
{
var b = m.OverriddenMethod;
while (b is not null)
{
if (!reachedMembers.Add(SignatureKey(b))) break;
basesChanged = true;
b = b.OverriddenMethod;
}
}
if (member is IPropertySymbol p && p.IsOverride && reachedMembers.Contains(SignatureKey(p)))
{
var b = p.OverriddenProperty;
while (b is not null)
{
if (!reachedMembers.Add(SignatureKey(b))) break;
if (b.GetMethod is not null) reachedMembers.Add(SignatureKey(b.GetMethod));
if (b.SetMethod is not null) reachedMembers.Add(SignatureKey(b.SetMethod));
basesChanged = true;
b = b.OverriddenProperty;
}
}
}
}
}
Console.Error.WriteLine($"[propagate] members after override-base chase: {reachedMembers.Count}");
// Build override map once: derivedMethod -> overriddenMethod (all walked).
// Also build interface-impl map: implMethod -> interface method.
bool changed = true;
int rounds = 0;
while (changed)
{
changed = false;
rounds++;
foreach (var t in reachedTypeList)
{
foreach (var m in t.GetMembers().OfType<IMethodSymbol>())
{
var key = SignatureKey(m);
if (reachedMembers.Contains(key)) continue;
// Override of a reached base method.
var ov = m.OverriddenMethod;
while (ov is not null)
{
if (reachedMembers.Contains(SignatureKey(ov)))
{
reachedMembers.Add(key);
changed = true;
break;
}
ov = ov.OverriddenMethod;
}
if (reachedMembers.Contains(key)) continue;
// Interface impl: is m the impl of some reached interface method?
foreach (var iface in t.AllInterfaces)
{
bool hit = false;
foreach (var ifaceM in iface.GetMembers().OfType<IMethodSymbol>())
{
if (!reachedMembers.Contains(SignatureKey(ifaceM))) continue;
var impl = t.FindImplementationForInterfaceMember(ifaceM);
if (impl is IMethodSymbol implM &&
SymbolEqualityComparer.Default.Equals(implM.OriginalDefinition, m.OriginalDefinition))
{
reachedMembers.Add(key);
changed = true;
hit = true;
break;
}
}
if (hit) break;
}
}
// Property/event/field accessors come with the property. If the property
// is reached, its accessors are reached too.
foreach (var p in t.GetMembers().OfType<IPropertySymbol>())
{
if (!reachedMembers.Contains(SignatureKey(p))) continue;
if (p.GetMethod is not null) reachedMembers.Add(SignatureKey(p.GetMethod));
if (p.SetMethod is not null) reachedMembers.Add(SignatureKey(p.SetMethod));
}
foreach (var ev in t.GetMembers().OfType<IEventSymbol>())
{
if (!reachedMembers.Contains(SignatureKey(ev))) continue;
if (ev.AddMethod is not null) reachedMembers.Add(SignatureKey(ev.AddMethod));
if (ev.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ev.RemoveMethod));
}
}
}
Console.Error.WriteLine($"[propagate] members after virtual/iface ({rounds} rounds): {reachedMembers.Count}");
// Cross-check against the runtime live-methods baseline. Any method named in the
// baseline must stay alive even if static analysis says dead — Harmony captures
// runtime patches we can't see statically.
var baselinePath = Path.Combine(
Path.GetDirectoryName(outputPath)!, "live-methods.baseline.txt");
var baseline = new HashSet<string>(StringComparer.Ordinal);
if (File.Exists(baselinePath))
{
foreach (var line in File.ReadAllLines(baselinePath))
{
var s = line.Trim();
if (s.Length > 0) baseline.Add(s);
}
Console.Error.WriteLine($"[baseline] loaded {baseline.Count} live-method names from {baselinePath}");
}
else
{
Console.Error.WriteLine($"[baseline] WARNING: not found at {baselinePath} — skipping cross-check");
}
// Emit dead-members.tsv covering every declared method/property/field/event in
// SVSim.BattleEngine that's in a reachable type but NOT referenced.
var deadMembersPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "dead-members.tsv");
var deadByFile = new Dictionary<string, List<DeadMember>>(StringComparer.OrdinalIgnoreCase);
// Symbols of dead members, grouped by file. Used by trim mode.
var deadSymbolsByFile = new Dictionary<string, List<ISymbol>>(StringComparer.OrdinalIgnoreCase);
int deadCount = 0;
int baselineRescues = 0;
foreach (var t in EnumerateTypes(engineComp.GlobalNamespace))
{
if (t.ContainingAssembly?.Name != CandidateAssembly) continue;
if (t.IsImplicitlyDeclared) continue;
if (!seen.Contains(TypeKey(t))) continue; // unreachable type — covered by provably-unreachable.tsv
var typeFullName = FullName(t);
foreach (var member in t.GetMembers())
{
if (member.IsImplicitlyDeclared) continue;
if (member is not (IMethodSymbol or IPropertySymbol or IFieldSymbol or IEventSymbol)) continue;
// Skip accessors — propagated via owning property/event above. Reporting them
// separately would double-count.
if (member is IMethodSymbol mm && mm.AssociatedSymbol is (IPropertySymbol or IEventSymbol)) continue;
if (reachedMembers.Contains(SignatureKey(member))) continue;
// Baseline rescue.
var baselineKey = $"{typeFullName.Replace("global::", "")}.{member.Name}";
if (baseline.Contains(baselineKey))
{
baselineRescues++;
continue;
}
// Reflection-name rescue: if the member's name appears anywhere as a string
// literal in reachable code, conservatively assume it could be a reflection
// target (GetMethod, GetField, SendMessage, Activator). Cheap defensive cover
// for paths the static walk can't see.
if (literalStrings.Contains(member.Name))
{
baselineRescues++;
continue;
}
foreach (var sref in member.DeclaringSyntaxReferences)
{
var path = sref.SyntaxTree.FilePath;
if (string.IsNullOrEmpty(path)) continue;
if (!deadByFile.TryGetValue(path, out var list))
{
list = new();
deadByFile[path] = list;
}
if (!deadSymbolsByFile.TryGetValue(path, out var symList))
{
symList = new();
deadSymbolsByFile[path] = symList;
}
symList.Add(member);
list.Add(new DeadMember(
Kind: member switch
{
IMethodSymbol mx when mx.MethodKind == MethodKind.Constructor => "ctor",
IMethodSymbol => "method",
IPropertySymbol => "property",
IFieldSymbol => "field",
IEventSymbol => "event",
_ => "?"
},
TypeFullName: typeFullName,
MemberName: member.Name,
Signature: member.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
deadCount++;
}
}
}
Console.Error.WriteLine($"[summary] dead members: {deadCount} (baseline rescues: {baselineRescues})");
await using (var w = new StreamWriter(deadMembersPath))
{
await w.WriteLineAsync("file_path\tkind\ttype\tmember_name\tsignature");
foreach (var (path, members) in deadByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
foreach (var m in members)
await w.WriteLineAsync($"{rel}\t{m.Kind}\t{m.TypeFullName}\t{m.MemberName}\t{m.Signature}");
}
}
Console.Error.WriteLine($"[output] wrote: {deadMembersPath}");
// Subset for actionable scope: Shim/Generated/*.g.cs files.
var generatedDeadPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "dead-members-generated.tsv");
int genDeadCount = 0;
int genFiles = 0;
await using (var w = new StreamWriter(generatedDeadPath))
{
await w.WriteLineAsync("file_path\tkind\ttype\tmember_name\tsignature");
foreach (var (path, members) in deadByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
if (!path.Replace('\\', '/').Contains("/Shim/Generated/")) continue;
genFiles++;
var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
foreach (var m in members)
{
await w.WriteLineAsync($"{rel}\t{m.Kind}\t{m.TypeFullName}\t{m.MemberName}\t{m.Signature}");
genDeadCount++;
}
}
}
Console.Error.WriteLine($"[output] wrote: {generatedDeadPath} ({genDeadCount} dead in {genFiles} generated files)");
// Trim mode moved below so it has access to both deadSymbolsByFile (member-level)
// and the deletableFiles list (type-level) — fully-unreachable files must be
// deleted before partial trims so their stale override declarations don't break
// the build when a reachable base method gets stripped.
// Enumerate every type in SVSim.BattleEngine. Group by file path.
// A file is fully deletable iff every type declared in it is unreachable.
var byFile = new Dictionary<string, FileRecord>(StringComparer.OrdinalIgnoreCase);
// typeFullName -> set of files declaring it (across partials).
var typeFiles = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
// file path -> list of (TypeSymbol, reachable) for partial-mixed type-level trim.
var typeSymbolsByFile = new Dictionary<string, List<(INamedTypeSymbol sym, bool reachable)>>(StringComparer.OrdinalIgnoreCase);
int totalEngineTypes = 0;
int unreachableEngineTypes = 0;
foreach (var t in EnumerateTypes(engineComp.GlobalNamespace))
{
if (t.ContainingAssembly?.Name != CandidateAssembly) continue;
// Skip implicitly-declared (compiler-generated, no source). These aren't deletion candidates.
if (t.IsImplicitlyDeclared) continue;
// Skip namespaces (shouldn't appear via GetTypeMembers but defensive).
if (t.TypeKind == TypeKind.Error) continue;
totalEngineTypes++;
bool reachable = seen.Contains(TypeKey(t));
if (!reachable) unreachableEngineTypes++;
var typeFull = FullName(t);
if (!typeFiles.TryGetValue(typeFull, out var typeFilesSet))
{
typeFilesSet = new(StringComparer.OrdinalIgnoreCase);
typeFiles[typeFull] = typeFilesSet;
}
foreach (var sref in t.DeclaringSyntaxReferences)
{
var path = sref.SyntaxTree.FilePath;
if (string.IsNullOrEmpty(path)) continue;
if (!byFile.TryGetValue(path, out var rec))
{
rec = new FileRecord();
byFile[path] = rec;
}
rec.Types.Add((typeFull, reachable));
typeFilesSet.Add(path);
if (!typeSymbolsByFile.TryGetValue(path, out var tsl))
{
tsl = new();
typeSymbolsByFile[path] = tsl;
}
tsl.Add((t, reachable));
}
}
Console.Error.WriteLine($"[summary] engine types: total={totalEngineTypes} unreachable={unreachableEngineTypes}");
var rawDeletableFiles = byFile
.Where(kv => kv.Value.Types.Count > 0 && kv.Value.Types.All(x => !x.reachable))
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
// Partial-class split-file guard. If a candidate-deletable file declares type T but T
// ALSO has a partial declaration in another file we are NOT deleting, removing this
// file leaves the surviving partial with its base-clause/interface-list pointing at
// members that lived only here — CS0535. Strip such files out of the deletable set
// and treat them as partial-mixed instead.
var rawDeletablePaths = new HashSet<string>(rawDeletableFiles.Select(kv => kv.Key), StringComparer.OrdinalIgnoreCase);
var deletableFiles = new List<KeyValuePair<string, FileRecord>>();
var splitRetained = new List<string>();
foreach (var kv in rawDeletableFiles)
{
bool safe = true;
foreach (var (typeFull, _) in kv.Value.Types)
{
if (!typeFiles.TryGetValue(typeFull, out var allPaths)) continue;
foreach (var p in allPaths)
{
if (StringComparer.OrdinalIgnoreCase.Equals(p, kv.Key)) continue;
if (!rawDeletablePaths.Contains(p))
{
safe = false;
break;
}
}
if (!safe) break;
}
if (safe) deletableFiles.Add(kv);
else splitRetained.Add(kv.Key);
}
if (splitRetained.Count > 0)
Console.Error.WriteLine($"[guard] partial-split-file retains: {splitRetained.Count} (e.g. {splitRetained[0]})");
var partialFiles = byFile
.Where(kv => kv.Value.Types.Any(x => !x.reachable) && kv.Value.Types.Any(x => x.reachable))
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
Console.Error.WriteLine($"[summary] files fully deletable: {deletableFiles.Count}");
Console.Error.WriteLine($"[summary] files partial (mixed): {partialFiles.Count}");
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
await using (var w = new StreamWriter(outputPath))
{
await w.WriteLineAsync("file_path\ttype_count\ttype_names");
foreach (var (path, rec) in deletableFiles)
{
var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
var names = string.Join(",", rec.Types.Select(x => x.full));
await w.WriteLineAsync($"{rel}\t{rec.Types.Count}\t{names}");
}
}
var partialPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "partial-unreachable.tsv");
await using (var w = new StreamWriter(partialPath))
{
await w.WriteLineAsync("file_path\tdead_count\tlive_count\tdead_types\tlive_types");
foreach (var (path, rec) in partialFiles)
{
var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
var dead = rec.Types.Where(x => !x.reachable).Select(x => x.full).ToList();
var live = rec.Types.Where(x => x.reachable).Select(x => x.full).ToList();
await w.WriteLineAsync($"{rel}\t{dead.Count}\t{live.Count}\t{string.Join(",", dead)}\t{string.Join(",", live)}");
}
}
Console.Error.WriteLine($"[output] wrote: {outputPath}");
Console.Error.WriteLine($"[output] wrote: {partialPath}");
if (trimMode)
{
// 1. Delete fully-unreachable files. Their declarations would otherwise have
// `override` clauses pointing at trimmed base methods, breaking the build.
int deletedFullFiles = 0;
foreach (var (path, _) in deletableFiles)
{
if (File.Exists(path))
{
File.Delete(path);
deletedFullFiles++;
}
}
Console.Error.WriteLine($"[trim] fully-unreachable files deleted: {deletedFullFiles}");
// 2. Surgical-trim member declarations from files we keep.
await ApplyTrim(deadSymbolsByFile, repoRoot);
// 3. Type-level surgical trim of partial-mixed Shim files. The bulk-delete
// path covers fully-unreachable files; this covers files where some types
// are dead but others (live) hold the file open. Targets the long-tail
// `_ShimAnchor` namespace-keepers and dead no-op stubs in Shim/External/,
// Shim/GodObjects/, Shim/UnityEngine/, Shim/View/.
await ApplyTypeTrim(typeSymbolsByFile, repoRoot);
}
return 0;
}
private static async Task ApplyTypeTrim(
Dictionary<string, List<(INamedTypeSymbol sym, bool reachable)>> typeSymbolsByFile,
string repoRoot)
{
int filesEdited = 0;
int typesRemoved = 0;
foreach (var (filePath, types) in typeSymbolsByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
// Scope to Shim/. Engine/ files in partial state are handled member-level above
// and a "dead type, live file" case in Engine/ would be unusual.
var norm = filePath.Replace('\\', '/');
if (!norm.Contains("/Shim/")) continue;
if (!File.Exists(filePath)) continue;
// Find dead TOP-LEVEL types whose only declaring file is this one (don't touch
// types whose partials live across multiple files — same split-file safety as
// the file-delete pass).
var deadHere = types
.Where(x => !x.reachable && x.sym.ContainingType is null)
.Select(x => x.sym)
.ToList();
if (deadHere.Count == 0) continue;
// Collect syntax nodes to remove.
var nodesByTree = new Dictionary<SyntaxTree, List<SyntaxNode>>();
foreach (var sym in deadHere)
{
foreach (var sref in sym.DeclaringSyntaxReferences)
{
if (sref.SyntaxTree.FilePath != filePath) continue;
var node = sref.GetSyntax();
var typeDecl = node.AncestorsAndSelf()
.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax>()
.FirstOrDefault();
if (typeDecl is null) continue;
if (!nodesByTree.TryGetValue(sref.SyntaxTree, out var list))
{
list = new();
nodesByTree[sref.SyntaxTree] = list;
}
if (!list.Contains(typeDecl)) list.Add(typeDecl);
}
}
if (nodesByTree.Count == 0) continue;
var (tree, matched) = nodesByTree.First();
var root = await tree.GetRootAsync();
var newRoot = root.RemoveNodes(matched, Microsoft.CodeAnalysis.SyntaxRemoveOptions.KeepNoTrivia);
if (newRoot is null) continue;
await File.WriteAllTextAsync(filePath, newRoot.ToFullString());
filesEdited++;
typesRemoved += matched.Count;
}
Console.Error.WriteLine($"[type-trim] files edited: {filesEdited}, types removed: {typesRemoved}");
}
private static async Task ApplyTrim(
Dictionary<string, List<ISymbol>> deadSymbolsByFile,
string repoRoot)
{
int filesEdited = 0;
int filesDeleted = 0;
int nodesRemoved = 0;
foreach (var (filePath, members) in deadSymbolsByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
{
// Trim scope: every BattleEngine source file. Shim/External/ third-party SDK
// stubs (Steamworks, Facebook, MessagePack, ZXing, CriWare, Spine, BestHTTP,
// GooglePlayGames, etc.) only existed because the decompiled engine UI called
// them; with the UI gone, nothing in BattleNode reaches them. The same logic
// applies to Shim/UnityEngine/* (UI/Loading API stubs) and Shim/View/* — they
// were defensive surface for UI paths that no longer exist headless. Trim them
// all; the analyzer's protection rules + the baseline-rescue net keep anything
// BattleNode actually calls. Engine/ never had a /Shim/ subdir, but the check
// is kept for clarity.
var norm = filePath.Replace('\\', '/');
bool inScope = norm.Contains("/SVSim.BattleEngine/");
if (!inScope) continue;
if (!File.Exists(filePath)) continue;
// The symbols' DeclaringSyntaxReferences point at the workspace compilation's
// syntax tree for this file. RemoveNodes operates on node identity within that
// tree — no name-matching needed, no risk of collapsing distinct members that
// happen to share an unqualified name (Clear() on outer instance vs nested
// Defaults.loopType etc).
var nodesByTree = new Dictionary<SyntaxTree, List<SyntaxNode>>();
foreach (var sym in members)
{
foreach (var sref in sym.DeclaringSyntaxReferences)
{
if (sref.SyntaxTree.FilePath != filePath) continue;
var node = sref.GetSyntax();
var memberDecl = node.AncestorsAndSelf()
.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax>()
.FirstOrDefault();
if (memberDecl is null) continue;
if (!nodesByTree.TryGetValue(sref.SyntaxTree, out var list))
{
list = new();
nodesByTree[sref.SyntaxTree] = list;
}
if (!list.Contains(memberDecl)) list.Add(memberDecl);
}
}
if (nodesByTree.Count == 0) continue;
var (tree, matched) = nodesByTree.First();
var root = await tree.GetRootAsync();
var newRoot = root.RemoveNodes(matched, Microsoft.CodeAnalysis.SyntaxRemoveOptions.KeepNoTrivia);
if (newRoot is null) continue;
// Do NOT delete the file even if every member is removed: the partial-class
// declaration carries the type's base-clause/interface-list which other
// generated files (e.g. _BaseClauses.g.cs) depend on, and a partial class
// declared nowhere else would be erased entirely. Just write back the shell.
await File.WriteAllTextAsync(filePath, newRoot.ToFullString());
filesEdited++;
nodesRemoved += matched.Count;
}
Console.Error.WriteLine($"[trim] files edited: {filesEdited}, files deleted: {filesDeleted}, nodes removed: {nodesRemoved}");
}
private static string? MemberKey(SyntaxNode node)
{
switch (node)
{
case Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax m:
return $"M:{m.Identifier.ValueText}({string.Join(",", m.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))})";
case Microsoft.CodeAnalysis.CSharp.Syntax.ConstructorDeclarationSyntax c:
return $"C:{c.Identifier.ValueText}({string.Join(",", c.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))})";
case Microsoft.CodeAnalysis.CSharp.Syntax.PropertyDeclarationSyntax p:
return $"P:{p.Identifier.ValueText}";
case Microsoft.CodeAnalysis.CSharp.Syntax.EventDeclarationSyntax e:
return $"E:{e.Identifier.ValueText}";
case Microsoft.CodeAnalysis.CSharp.Syntax.EventFieldDeclarationSyntax ef:
var n0 = ef.Declaration.Variables.FirstOrDefault()?.Identifier.ValueText;
return n0 is null ? null : $"EF:{n0}";
case Microsoft.CodeAnalysis.CSharp.Syntax.FieldDeclarationSyntax f:
// Multiple variables in one declaration: key on the first. The trimmer only
// emits whole-declaration removal — if some variables in a multi-decl are
// alive, the analyzer's per-symbol output already separated them, so they
// get distinct DeclaringSyntaxReferences. Keep simple: key on first.
var fname = f.Declaration.Variables.FirstOrDefault()?.Identifier.ValueText;
return fname is null ? null : $"F:{fname}";
case Microsoft.CodeAnalysis.CSharp.Syntax.IndexerDeclarationSyntax ix:
return $"I:[{string.Join(",", ix.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))}]";
default:
return null;
}
}
// Display-string signature used as a cross-compilation-stable member identity.
// For methods, includes container, name, and parameter types.
private static readonly SymbolDisplayFormat KeyFormat = new(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeContainingType
| SymbolDisplayMemberOptions.IncludeType
| SymbolDisplayMemberOptions.IncludeParameters,
parameterOptions: SymbolDisplayParameterOptions.IncludeType,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
private static string SignatureKey(ISymbol sym)
{
var orig = sym.OriginalDefinition;
return orig.ToDisplayString(KeyFormat);
}
private static void RecordMember(ISymbol? sym, HashSet<string> reached)
{
if (sym is null) return;
// Extension method invocations resolve to a "reduced" IMethodSymbol whose identity
// differs from the static declaration. Walk to the underlying static method.
if (sym is IMethodSymbol rm && rm.ReducedFrom is not null) sym = rm.ReducedFrom;
switch (sym)
{
case IMethodSymbol m:
reached.Add(SignatureKey(m));
if (m.AssociatedSymbol is not null)
reached.Add(SignatureKey(m.AssociatedSymbol));
break;
case IPropertySymbol p:
reached.Add(SignatureKey(p));
if (p.GetMethod is not null) reached.Add(SignatureKey(p.GetMethod));
if (p.SetMethod is not null) reached.Add(SignatureKey(p.SetMethod));
break;
case IFieldSymbol f:
reached.Add(SignatureKey(f));
break;
case IEventSymbol e:
reached.Add(SignatureKey(e));
if (e.AddMethod is not null) reached.Add(SignatureKey(e.AddMethod));
if (e.RemoveMethod is not null) reached.Add(SignatureKey(e.RemoveMethod));
break;
}
}
private static void AddSymbol(ISymbol? sym, Queue<INamedTypeSymbol> q)
{
if (sym is null) return;
// Extension method invocations resolve to a reduced IMethodSymbol whose
// ContainingType is the receiver, not the static-method-declaring class.
if (sym is IMethodSymbol rm && rm.ReducedFrom is not null) sym = rm.ReducedFrom;
switch (sym)
{
case INamedTypeSymbol nt:
Enqueue(nt, q);
foreach (var ta in nt.TypeArguments) EnqueueAny(ta, q);
break;
case IMethodSymbol m:
if (m.ContainingType is not null) Enqueue(m.ContainingType, q);
EnqueueAny(m.ReturnType, q);
foreach (var p in m.Parameters) EnqueueAny(p.Type, q);
foreach (var ta in m.TypeArguments) EnqueueAny(ta, q);
break;
case IPropertySymbol p:
if (p.ContainingType is not null) Enqueue(p.ContainingType, q);
EnqueueAny(p.Type, q);
break;
case IFieldSymbol f:
if (f.ContainingType is not null) Enqueue(f.ContainingType, q);
EnqueueAny(f.Type, q);
break;
case IEventSymbol e:
if (e.ContainingType is not null) Enqueue(e.ContainingType, q);
EnqueueAny(e.Type, q);
break;
}
}
private static void EnqueueAny(ITypeSymbol? type, Queue<INamedTypeSymbol> q)
{
if (type is null) return;
switch (type)
{
case IArrayTypeSymbol a:
EnqueueAny(a.ElementType, q);
break;
case IPointerTypeSymbol p:
EnqueueAny(p.PointedAtType, q);
break;
case INamedTypeSymbol nt:
Enqueue(nt, q);
foreach (var ta in nt.TypeArguments) EnqueueAny(ta, q);
break;
case ITypeParameterSymbol tp:
foreach (var con in tp.ConstraintTypes) EnqueueAny(con, q);
break;
}
}
private static void Enqueue(INamedTypeSymbol type, Queue<INamedTypeSymbol> q)
{
q.Enqueue((INamedTypeSymbol)type.OriginalDefinition);
}
private static IEnumerable<INamedTypeSymbol> EnumerateTypes(INamespaceOrTypeSymbol root)
{
if (root is INamespaceSymbol ns)
{
foreach (var t in ns.GetTypeMembers())
{
yield return t;
foreach (var n in EnumerateNestedTypes(t)) yield return n;
}
foreach (var sub in ns.GetNamespaceMembers())
foreach (var t in EnumerateTypes(sub))
yield return t;
}
else if (root is INamedTypeSymbol nt)
{
yield return nt;
foreach (var n in EnumerateNestedTypes(nt)) yield return n;
}
}
private static IEnumerable<INamedTypeSymbol> EnumerateNestedTypes(INamedTypeSymbol t)
{
foreach (var n in t.GetTypeMembers())
{
yield return n;
foreach (var nn in EnumerateNestedTypes(n)) yield return nn;
}
}
private static bool HasNUnitAttribute(INamedTypeSymbol t)
{
foreach (var a in t.GetAttributes())
if (NUnitAttributeNames.Contains(a.AttributeClass?.Name ?? ""))
return true;
foreach (var m in t.GetMembers())
foreach (var a in m.GetAttributes())
if (NUnitAttributeNames.Contains(a.AttributeClass?.Name ?? ""))
return true;
return false;
}
private static string FullName(INamedTypeSymbol t) =>
t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
for (int i = 0; i < 10; i++)
{
if (File.Exists(Path.Combine(dir, "DCGEngine.sln")))
return Path.GetFullPath(dir);
var parent = Directory.GetParent(dir)?.FullName;
if (parent is null || parent == dir) break;
dir = parent;
}
throw new InvalidOperationException(
"could not locate DCGEngine.sln walking up from " + AppContext.BaseDirectory);
}
private sealed class FileRecord
{
public List<(string full, bool reachable)> Types { get; } = new();
}
private sealed record DeadMember(string Kind, string TypeFullName, string MemberName, string Signature);
}