feat: integrate performance upgrade and agent inspection tooling
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user