1171 lines
56 KiB
C#
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);
|
|
}
|