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(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(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()) 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)); } } }