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 NUnitAttributeNames = new(StringComparer.Ordinal) { "TestAttribute", "TestCaseAttribute", "TestCaseSourceAttribute", "TestFixtureAttribute", "TestFixtureSourceAttribute", "TheoryAttribute", "OneTimeSetUpAttribute", "OneTimeTearDownAttribute", "SetUpAttribute", "TearDownAttribute", "SetUpFixtureAttribute", }; private static readonly HashSet 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 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(); 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(); 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(); var seen = new HashSet(StringComparer.Ordinal); var seenTypeSymbols = new List(); // 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(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(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, 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()) { 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()) { 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()) { 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()) { 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(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>(StringComparer.OrdinalIgnoreCase); // Symbols of dead members, grouped by file. Used by trim mode. var deadSymbolsByFile = new Dictionary>(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(StringComparer.OrdinalIgnoreCase); // typeFullName -> set of files declaring it (across partials). var typeFiles = new Dictionary>(StringComparer.Ordinal); // file path -> list of (TypeSymbol, reachable) for partial-mixed type-level trim. var typeSymbolsByFile = new Dictionary>(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(rawDeletableFiles.Select(kv => kv.Key), StringComparer.OrdinalIgnoreCase); var deletableFiles = new List>(); var splitRetained = new List(); 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> 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>(); 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() .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> 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>(); 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() .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 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 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 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 q) { q.Enqueue((INamedTypeSymbol)type.OriginalDefinition); } private static IEnumerable 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 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); }