feat: integrate performance upgrade and agent inspection tooling
Validate ShrinkSDK Workspace / catalog (push) Successful in 5s
Validate ShrinkSDK Workspace / unity (push) Successful in 3m20s

This commit is contained in:
2026-09-29 10:16:02 +08:00
parent f92105c97d
commit 08246ddb01
114 changed files with 7135 additions and 87 deletions
@@ -0,0 +1,202 @@
using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using ShrinkSDK.CodeGen.Analyzers;
namespace ShrinkSDK.Inspect;
public static class SemanticIndex
{
public static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true, WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
public static async Task Build(Index index, string? manifestPath, string? projectPath, bool checkRules = false)
{
if (projectPath != null)
{
if (!MSBuildLocator.IsRegistered) MSBuildLocator.RegisterDefaults();
await LoadProject(index, Path.GetFullPath(projectPath), checkRules);
return;
}
if (manifestPath != null)
{
var manifest = JsonSerializer.Deserialize<CompilationManifest>(File.ReadAllText(manifestPath), Json) ?? throw new InvalidDataException("Invalid compilation manifest.");
index.BuildId = manifest.BuildId;
var stale = manifest.SourceTicks.Where(pair => !File.Exists(pair.Key) || File.GetLastWriteTimeUtc(pair.Key).Ticks != pair.Value).Take(5).Select(p => p.Key).ToArray();
if (stale.Length > 0) { index.Coverage.Add("Stale compilation input: " + string.Join(", ", stale)); index.BuildId = ""; }
if (manifest.SourceTicks.Count == 0 || manifest.Assemblies.SelectMany(a => a.Sources).Any(s => !manifest.SourceTicks.ContainsKey(s)))
{ index.Coverage.Add("Compilation manifest lacks complete source state; runtime identity cannot be verified."); index.BuildId = ""; }
foreach (var input in manifest.Assemblies)
{
var sources = input.Sources.Where(path => InScope(index.Root, path)).ToArray();
if (sources.Length == 0) continue;
input.Sources = sources;
Analyze(index, Compile(input), checkRules: checkRules);
}
index.Coverage.Add("Installed ShrinkSDK package sources are included; other external packages are reference-only.");
return;
}
var projects = Directory.GetFiles(index.Root, "*.csproj");
var found = false;
foreach (var path in projects)
{
var xml = XDocument.Load(path);
var sources = xml.Descendants().Where(e => e.Name.LocalName == "Compile").Select(e => e.Attribute("Include")?.Value)
.Where(s => s != null && !s.Contains('*')).Select(s => Path.GetFullPath(Path.Combine(index.Root, s!))).Where(File.Exists).ToArray();
if (sources.Length == 0) continue;
var references = xml.Descendants().Where(e => e.Name.LocalName == "HintPath").Select(e => Path.GetFullPath(Path.Combine(index.Root, e.Value))).Where(File.Exists).ToArray();
var projectReferences = xml.Descendants().Where(e => e.Name.LocalName == "ProjectReference").Select(e => e.Attribute("Include")?.Value)
.Where(p => p != null).Select(p => Path.Combine(index.Root, "Library", "ScriptAssemblies", Path.GetFileNameWithoutExtension(p!) + ".dll")).Where(File.Exists);
references = references.Concat(projectReferences).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var defines = xml.Descendants().FirstOrDefault(e => e.Name.LocalName == "DefineConstants")?.Value.Split(';') ?? [];
Analyze(index, Compile(new() { Name = Path.GetFileNameWithoutExtension(path), Sources = sources, References = references, Defines = defines }), checkRules: checkRules);
found = true;
}
if (found) { index.Coverage.Add("Unity IDE project inputs may be stale. Export compilation.json from the Editor for verified references/defines and runtime identity."); return; }
index.Coverage.Add("No compilation input: source-only candidate index; conditional compilation and references are incomplete. Use --compilation or --project.");
Analyze(index, Compile(new() { Name = "SourceOnly", Sources = ModuleCatalog.Files(index.Root, "*.cs").ToArray(),
References = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?.Split(Path.PathSeparator) ?? [] }), candidateOnly: true);
}
private static async Task LoadProject(Index index, string path, bool checkRules)
{
using var workspace = MSBuildWorkspace.Create();
workspace.WorkspaceFailed += (_, e) => index.Coverage.Add(e.Diagnostic.Message);
var project = await workspace.OpenProjectAsync(path);
foreach (var p in project.Solution.Projects)
{
var compilation = await p.GetCompilationAsync();
if (compilation != null) Analyze(index, compilation, checkRules: checkRules);
}
index.Coverage.Add("Current source project analysis; no runtime build identity unless a matching exported manifest is supplied.");
}
private static bool InScope(string root, string path)
{
var relative = Path.GetRelativePath(root, path);
var parts = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (parts.Length > 2 && parts[0] == "Library" && parts[1] == "PackageCache" && parts[2].StartsWith("com.cneicy.shrink-", StringComparison.Ordinal)) return true;
return !relative.StartsWith("..") && !relative.Split(Path.DirectorySeparatorChar).Any(p => p is "Library" or "Temp" or "obj" or "bin" or "Artifacts");
}
private static Compilation Compile(CompilationInput input)
{
var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest).WithPreprocessorSymbols(input.Defines.Where(d => !string.IsNullOrWhiteSpace(d)));
var trees = input.Sources.Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase)
.Select(path => CSharpSyntaxTree.ParseText(File.ReadAllText(path), options, path));
var references = input.References.Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).Select(path => MetadataReference.CreateFromFile(path));
return CSharpCompilation.Create(input.Name, trees, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true));
}
public static string Id(ISymbol symbol) => symbol.OriginalDefinition.GetDocumentationCommentId() ?? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
private static SourceLocation At(SyntaxNode node) => new(node.SyntaxTree.FilePath, node.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
private static string TypeId(ITypeSymbol type) => "type:" + type.ToDisplayString();
public static void Analyze(Index index, Compilation compilation, bool candidateOnly = false, bool checkRules = false)
{
var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3).ToArray();
if (errors.Length > 0) index.Coverage.Add($"{compilation.AssemblyName}: incomplete compilation: " + string.Join(" | ", errors.Select(e => e.Id + " " + e.GetMessage())));
if (checkRules && !candidateOnly)
{
var diagnostics = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(new ShrinkCodeGenAnalyzer()))
.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult();
foreach (var diagnostic in diagnostics)
{
var line = diagnostic.Location.GetLineSpan();
index.Findings.Add(new(diagnostic.Id, diagnostic.Severity.ToString().ToLowerInvariant(), diagnostic.GetMessage(),
new(string.IsNullOrEmpty(line.Path) ? index.Root : line.Path, line.StartLinePosition.Line + 1)));
}
}
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var node in tree.GetRoot().DescendantNodes())
{
if (node is BaseTypeDeclarationSyntax or BaseMethodDeclarationSyntax or PropertyDeclarationSyntax)
{
var symbol = model.GetDeclaredSymbol(node);
if (symbol == null) continue;
var id = Id(symbol);
index.Symbols[id] = new(id, symbol.ToDisplayString(), index.ModuleFor(tree.FilePath), symbol.Kind.ToString(), At(node));
if (symbol is INamedTypeSymbol type)
{
Edge(id, TypeId(type), "declares", candidateOnly ? "candidate" : "static", node);
foreach (var attr in type.GetAttributes().Where(a => a.AttributeClass?.ToDisplayString() == "ShrinkNetwork.ShrinkNetworkMessageAttribute"))
{
if (attr.ConstructorArguments.Length == 0) continue;
Edge("opcode:" + attr.ConstructorArguments[0].Value, TypeId(type), "message", "static", node);
if (attr.ConstructorArguments.Length > 1 && attr.ConstructorArguments[1].Value is string route && !string.IsNullOrWhiteSpace(route)) Edge("route:" + route.Trim(), TypeId(type), "message", "static", node);
}
}
foreach (var attr in symbol.GetAttributes())
{
var attrName = attr.AttributeClass?.ToDisplayString();
if (symbol is IMethodSymbol method && attrName == "ShrinkEventBus.ShrinkSubscribeAttribute" && method.Parameters.Length > 0)
Edge(TypeId(method.Parameters[0].Type), id, "subscribes", "candidate", node);
if (attrName?.Contains("ShrinkCommand", StringComparison.Ordinal) == true && attr.ConstructorArguments.FirstOrDefault().Value is string command)
Edge("command:" + command, id, "command", "candidate", node);
}
}
if (node is not InvocationExpressionSyntax invocation) continue;
var info = model.GetSymbolInfo(invocation);
var target = info.Symbol as IMethodSymbol;
var enclosing = model.GetEnclosingSymbol(invocation.SpanStart);
if (enclosing == null) continue;
var from = Id(enclosing);
if (target == null)
{
foreach (var candidate in info.CandidateSymbols) Edge(from, Id(candidate), "calls", "candidate", node);
if (info.CandidateSymbols.Length == 0) Edge(from, invocation.Expression.ToString(), "calls", "unresolved", node);
continue;
}
var evidence = candidateOnly || target.IsVirtual || target.ContainingType.TypeKind == TypeKind.Interface || target.MethodKind == MethodKind.DelegateInvoke ? "candidate" : "static";
Edge(from, Id(target), "calls", evidence, node);
var owner = target.ContainingNamespace.ToDisplayString();
if (owner.StartsWith("ShrinkEventBus", StringComparison.Ordinal) && target.Name is "Post" or "PostAsync" && invocation.ArgumentList.Arguments.Count > 0)
{
var type = model.GetTypeInfo(invocation.ArgumentList.Arguments[0].Expression).Type;
if (type != null) Edge(from, TypeId(type), "publishes", evidence, node);
}
if (owner.StartsWith("ShrinkContext", StringComparison.Ordinal) && target.Name is "Get" or "Set" or "TryGet" && invocation.ArgumentList.Arguments.Count > 0)
{
var arg = invocation.ArgumentList.Arguments[0].Expression;
var key = model.GetConstantValue(arg);
var keyName = key.HasValue && key.Value is string value ? value : model.GetSymbolInfo(arg).Symbol?.ToDisplayString() ?? arg.ToString();
var type = target.TypeArguments.FirstOrDefault()?.ToDisplayString() ?? "unknown";
Edge(from, "key:" + keyName, target.Name == "Set" ? "provides:" + type : "consumes:" + type, evidence, node);
}
if (owner.StartsWith("ShrinkNetwork", StringComparison.Ordinal) && target.Name.StartsWith("Register", StringComparison.Ordinal) && target.TypeArguments.Length > 0)
{
Edge(TypeId(target.TypeArguments[0]), from, "registers", "candidate", node);
foreach (var arg in invocation.ArgumentList.Arguments)
{
var handler = model.GetSymbolInfo(arg.Expression).Symbol;
if (handler is IMethodSymbol) Edge(TypeId(target.TypeArguments[0]), Id(handler), "handler", "candidate", arg);
if (arg.Expression is LambdaExpressionSyntax lambda)
foreach (var call in lambda.DescendantNodes().OfType<InvocationExpressionSyntax>())
if (model.GetSymbolInfo(call).Symbol is IMethodSymbol forwarded)
Edge(TypeId(target.TypeArguments[0]), Id(forwarded), "handler-forward", "candidate", call);
}
}
}
}
void Edge(string from, string to, string kind, string evidence, SyntaxNode node) => index.Relations.Add(new(from, to, kind, evidence, At(node)));
}
public static void CheckRelations(Index index)
{
foreach (var group in index.Relations.Where(r => r.Kind == "message" && r.Evidence == "static").GroupBy(r => r.From))
if (group.Select(r => r.To).Distinct().Count() > 1)
foreach (var edge in group) index.Findings.Add(new("SHRINK002", "error", $"Duplicate {group.Key}. Assign a unique opcode and nonempty route; see Network README.", edge.Location));
foreach (var group in index.Relations.Where(r => r.Evidence == "static" && (r.Kind.StartsWith("provides:") || r.Kind.StartsWith("consumes:"))).GroupBy(r => r.To))
{
var provided = group.Where(r => r.Kind.StartsWith("provides:")).Select(r => r.Kind[9..]).Distinct().ToArray();
// Exact generic type mismatches may be legal covariance/interface use. Report for review, not as a proof of invalidity.
if (provided.Length > 1) index.Findings.Add(new("SHRINK104", "warning", $"{group.Key} is provided with different generic types: {string.Join(", ", provided)}. Check realm and assignability.", group.First().Location));
}
}
}