feat: integrate performance upgrade and agent inspection tooling
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
namespace ShrinkSDK.Inspect;
|
||||
|
||||
public sealed record SourceLocation(string File, int Line);
|
||||
public sealed record SymbolInfo(string Id, string Name, string Module, string Kind, SourceLocation Location);
|
||||
public sealed record Relation(string From, string To, string Kind, string Evidence, SourceLocation Location);
|
||||
public sealed record RuntimeState(string Scope, string Name, System.Text.Json.JsonElement Value, SourceLocation Location);
|
||||
public sealed record Finding(string Code, string Severity, string Message, SourceLocation Location);
|
||||
public sealed record Capability(string Module, string Summary, string Aliases, string Limits, string Extension,
|
||||
string Evidence, SourceLocation Location, int Score = 0, string MatchReason = "");
|
||||
public sealed record ModuleInfo(string Name, string Root, string Version, string[] Dependencies,
|
||||
string[] Assemblies, string[] Validation, bool SemanticDescriptionComplete);
|
||||
public sealed class CompilationInput
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string[] Sources { get; set; } = [];
|
||||
public string[] References { get; set; } = [];
|
||||
public string[] Defines { get; set; } = [];
|
||||
public string Output { get; set; } = "";
|
||||
}
|
||||
public sealed class CompilationManifest
|
||||
{
|
||||
public string Root { get; set; } = "";
|
||||
public string BuildId { get; set; } = "";
|
||||
public CompilationInput[] Assemblies { get; set; } = [];
|
||||
public Dictionary<string, long> SourceTicks { get; set; } = new();
|
||||
}
|
||||
public sealed class Index
|
||||
{
|
||||
public string Root { get; set; } = "";
|
||||
public string BuildId { get; set; } = "";
|
||||
public List<string> Coverage { get; } = [];
|
||||
public List<ModuleInfo> Modules { get; } = [];
|
||||
public List<Capability> Capabilities { get; } = [];
|
||||
public Dictionary<string, SymbolInfo> Symbols { get; } = new(StringComparer.Ordinal);
|
||||
public List<Relation> Relations { get; } = [];
|
||||
public List<RuntimeState> RuntimeStates { get; } = [];
|
||||
public List<Finding> Findings { get; } = [];
|
||||
public string ModuleFor(string file) => Modules.Where(m => file.StartsWith(m.Root + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase)).OrderByDescending(m => m.Root.Length).FirstOrDefault()?.Name ?? "consumer";
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ShrinkSDK.Inspect;
|
||||
|
||||
public static class ModuleCatalog
|
||||
{
|
||||
private static readonly HashSet<string> Excluded = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".git", "Library", "Temp", "obj", "bin", "Logs", "Builds", "Artifacts", "node_modules", "Development~", "Samples~" };
|
||||
|
||||
public static IEnumerable<string> Files(string root, string pattern)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(root, pattern)) yield return file;
|
||||
foreach (var dir in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
if (Excluded.Contains(Path.GetFileName(dir)) || (File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0) continue;
|
||||
foreach (var file in Files(dir, pattern)) yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Load(Index index)
|
||||
{
|
||||
var installed = Path.Combine(index.Root, "Library", "PackageCache");
|
||||
var scanRoots = new List<string> { index.Root };
|
||||
if (Directory.Exists(installed)) scanRoots.AddRange(Directory.GetDirectories(installed, "com.cneicy.shrink-*@*"));
|
||||
var packages = scanRoots.SelectMany(r => Files(r, "package.json")).Select(path => (path, doc: Read(path)))
|
||||
.Where(x => x.doc.TryGetProperty("name", out _)).ToArray();
|
||||
var asmdefs = scanRoots.SelectMany(r => Files(r, "*.asmdef")).ToArray();
|
||||
var roots = packages.Select(p => Path.GetDirectoryName(p.path)!).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in asmdefs)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path)!;
|
||||
if (!roots.Any(root => dir.Equals(root, StringComparison.OrdinalIgnoreCase) || dir.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) roots.Add(dir);
|
||||
}
|
||||
// Business directories can share an asmdef while retaining separate ownership.
|
||||
foreach (var readme in scanRoots.SelectMany(r => Files(r, "README.md")))
|
||||
if (File.ReadAllText(readme).Contains("<!-- shrink:capabilities -->", StringComparison.Ordinal)) roots.Add(Path.GetDirectoryName(readme)!);
|
||||
|
||||
foreach (var root in roots.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var package = packages.FirstOrDefault(p => Path.GetDirectoryName(p.path) == root);
|
||||
var name = package.doc.ValueKind == JsonValueKind.Object ? String(package.doc, "name") : Path.GetFileName(root);
|
||||
var version = package.doc.ValueKind == JsonValueKind.Object ? String(package.doc, "version") : "unversioned";
|
||||
var dependencies = package.doc.ValueKind == JsonValueKind.Object && package.doc.TryGetProperty("dependencies", out var deps)
|
||||
? deps.EnumerateObject().Select(p => p.Name + "@" + p.Value.GetString()).ToArray() : [];
|
||||
var assemblies = asmdefs.Where(p => p.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => String(Read(p), "name")).ToArray();
|
||||
var readme = Path.Combine(root, "README.md");
|
||||
var complete = File.Exists(readme) && ReadCapabilities(name, readme, index.Capabilities);
|
||||
var validation = Files(root, "*.cs").Where(p => p.Contains("Tests", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => Path.GetRelativePath(index.Root, p)).Take(8).ToArray();
|
||||
index.Modules.Add(new(name, root, version, dependencies, assemblies, validation, complete));
|
||||
}
|
||||
foreach (var module in index.Modules)
|
||||
foreach (var dep in module.Dependencies)
|
||||
{
|
||||
var split = dep.LastIndexOf('@');
|
||||
if (split <= 0) continue;
|
||||
var target = index.Modules.FirstOrDefault(m => m.Name == dep[..split]);
|
||||
if (target == null) continue;
|
||||
if (target.Version != dep[(split + 1)..]) index.Findings.Add(new("SHRINK101", "error", $"{module.Name} requires {dep}, workspace has {target.Version}", new(Path.Combine(module.Root, "package.json"), 1)));
|
||||
if (!module.Name.Contains("integration") && !module.Name.Contains("starter") && target.Name.Contains("-integration-"))
|
||||
index.Findings.Add(new("SHRINK102", "error", $"Main package {module.Name} depends on integration {target.Name}.", new(Path.Combine(module.Root, "package.json"), 1)));
|
||||
}
|
||||
foreach (var module in index.Modules)
|
||||
Visit(module, new HashSet<string>(), new HashSet<string>());
|
||||
void Visit(ModuleInfo current, HashSet<string> path, HashSet<string> seen)
|
||||
{
|
||||
if (!path.Add(current.Name)) { index.Findings.Add(new("SHRINK103", "error", "Package dependency cycle: " + string.Join(" -> ", path) + " -> " + current.Name, new(Path.Combine(current.Root, "package.json"), 1))); return; }
|
||||
if (seen.Add(current.Name))
|
||||
foreach (var dep in current.Dependencies)
|
||||
{
|
||||
var target = index.Modules.FirstOrDefault(m => dep.StartsWith(m.Name + "@", StringComparison.Ordinal));
|
||||
if (target != null) Visit(target, path, seen);
|
||||
}
|
||||
path.Remove(current.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReadCapabilities(string module, string path, List<Capability> output)
|
||||
{
|
||||
var lines = File.ReadAllLines(path);
|
||||
var start = Array.IndexOf(lines, "<!-- shrink:capabilities -->");
|
||||
var end = Array.IndexOf(lines, "<!-- /shrink:capabilities -->");
|
||||
if (start < 0 || end <= start) return false;
|
||||
string Field(string key) => lines.Skip(start + 1).Take(end - start - 1)
|
||||
.FirstOrDefault(s => s.StartsWith(key + ":", StringComparison.Ordinal))?[(key.Length + 1)..].Trim() ?? "";
|
||||
output.Add(new(module, Field("Capability"), Field("Aliases"), Field("Limits"), Field("Extension"), Field("Evidence"), new(path, start + 1)));
|
||||
return new[] { "Capability", "Limits", "Extension", "Evidence" }.All(key => Field(key).Length > 0);
|
||||
}
|
||||
|
||||
public static IEnumerable<Capability> Search(Index index, string query, string? module)
|
||||
{
|
||||
var terms = Regex.Split(query.Trim(), @"[\s,,;;/]+").Where(t => t.Length > 0).ToArray();
|
||||
return index.Capabilities.Where(c => module == null || c.Module.Contains(module, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => {
|
||||
var text = c.Module + " " + c.Summary + " " + c.Aliases;
|
||||
var matches = terms.Where(t => text.Contains(t, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
return c with { Score = matches.Length, MatchReason = string.Join(", ", matches) };
|
||||
}).Where(c => terms.Length == 0 || c.Score > 0).OrderByDescending(c => c.Score).ThenBy(c => c.Module, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static JsonElement Read(string path) { using var doc = JsonDocument.Parse(File.ReadAllText(path)); return doc.RootElement.Clone(); }
|
||||
private static string String(JsonElement e, string name) => e.TryGetProperty(name, out var p) ? p.GetString() ?? "" : "";
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using ShrinkSDK.Inspect;
|
||||
using Index = ShrinkSDK.Inspect.Index;
|
||||
|
||||
return await InspectCommand.Run(args);
|
||||
|
||||
public static class InspectCommand
|
||||
{
|
||||
public static async Task<int> Run(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length < 2 || args[0] != "inspect")
|
||||
{
|
||||
Console.WriteLine("shrink inspect modules|capabilities|symbol|flow|impact|check [query] [--root dir] [--module name] [--limit n] [--depth n] [--json] [--compilation file|--project csproj] [--snapshot file]");
|
||||
return args.Length == 0 ? 0 : 2;
|
||||
}
|
||||
var options = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var words = new List<string>();
|
||||
for (var i = 2; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--json") options["json"] = "true";
|
||||
else if (args[i].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = args[i][2..];
|
||||
if (key is not ("root" or "module" or "limit" or "offset" or "depth" or "compilation" or "project" or "snapshot")) throw new ArgumentException("Unknown option --" + key);
|
||||
if (++i >= args.Length) throw new ArgumentException("Missing value for --" + key);
|
||||
options[key] = args[i];
|
||||
}
|
||||
else words.Add(args[i]);
|
||||
}
|
||||
string? Option(string key) => options.GetValueOrDefault(key);
|
||||
var command = args[1];
|
||||
if (command is not ("modules" or "capabilities" or "symbol" or "flow" or "impact" or "check")) throw new ArgumentException("Unknown query " + command);
|
||||
var query = string.Join(" ", words);
|
||||
if (command is "symbol" or "flow" or "impact" && query.Length == 0) throw new ArgumentException("An exact symbol, route, key or file query is required.");
|
||||
var limit = int.Parse(Option("limit") ?? (command == "capabilities" ? "3" : "20"));
|
||||
var offset = int.Parse(Option("offset") ?? "0");
|
||||
var depth = int.Parse(Option("depth") ?? "2");
|
||||
if (limit is < 1 or > 1000 || depth is < 0 or > 10 || offset < 0) throw new ArgumentException("limit must be 1..1000; depth must be 0..10; offset must be nonnegative.");
|
||||
var index = new Index { Root = Path.GetFullPath(Option("root") ?? Environment.CurrentDirectory) };
|
||||
if (!Directory.Exists(index.Root)) throw new DirectoryNotFoundException(index.Root);
|
||||
if (Option("project") != null && Option("compilation") != null) throw new ArgumentException("Choose --project or --compilation, not both.");
|
||||
ModuleCatalog.Load(index);
|
||||
if (command is not ("modules" or "capabilities"))
|
||||
{
|
||||
await SemanticIndex.Build(index, Option("compilation"), Option("project"), checkRules: command == "check");
|
||||
index.Relations.RemoveAll(r => string.IsNullOrEmpty(r.From) || string.IsNullOrEmpty(r.To));
|
||||
var unique = index.Relations.Distinct().ToArray();
|
||||
index.Relations.Clear(); index.Relations.AddRange(unique);
|
||||
SemanticIndex.CheckRelations(index);
|
||||
}
|
||||
if (Option("snapshot") is { } snapshot) AttachSnapshot(index, snapshot);
|
||||
var moduleFilter = Option("module");
|
||||
var stateQuery = query.StartsWith("key:", StringComparison.Ordinal) ? query[4..] : query;
|
||||
bool MatchModule(string module) => moduleFilter == null || module.Contains(moduleFilter, StringComparison.OrdinalIgnoreCase);
|
||||
object[] all = command switch
|
||||
{
|
||||
"modules" => index.Modules.Where(m => MatchModule(m.Name)).Cast<object>().ToArray(),
|
||||
"capabilities" => ModuleCatalog.Search(index, query, moduleFilter).Cast<object>().ToArray(),
|
||||
"symbol" => index.Symbols.Values.Where(s => MatchModule(s.Module) && (s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Name.Contains(query, StringComparison.OrdinalIgnoreCase))).OrderBy(s => s.Id).Cast<object>().Concat(Walk(index, query, 1, moduleFilter)).ToArray(),
|
||||
"flow" or "impact" => Walk(index, query, depth, moduleFilter).Cast<object>().Concat(index.RuntimeStates.Where(s => s.Scope.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Value.ToString().Contains(stateQuery, StringComparison.OrdinalIgnoreCase))).ToArray(),
|
||||
"check" => index.Findings.Distinct().Cast<object>().ToArray(),
|
||||
_ => []
|
||||
};
|
||||
var missingDescriptions = index.Modules.Where(m => MatchModule(m.Name) && !m.SemanticDescriptionComplete).Select(m => m.Name).ToArray();
|
||||
var result = new {
|
||||
success = true, formatVersion = 1, toolVersion = typeof(InspectCommand).Assembly.GetName().Version?.ToString(), command, query, buildId = index.BuildId,
|
||||
offset, limit, nextOffset = all.Length > offset + limit ? (int?)(offset + limit) : null,
|
||||
coverage = index.Coverage.Distinct().Take(12), coverageTruncated = index.Coverage.Distinct().Count() > 12,
|
||||
semanticCoverage = new { incompleteCount = missingDescriptions.Length, examples = missingDescriptions.Take(5) },
|
||||
validation = command == "impact" ? index.Modules.Where(m => all.OfType<Relation>().Any(r => index.ModuleFor(r.Location.File) == m.Name)).SelectMany(m => m.Validation).Distinct().Take(8).ToArray() : Array.Empty<string>(),
|
||||
total = all.Length, truncated = all.Length > offset + limit, items = all.Skip(offset).Take(limit)
|
||||
};
|
||||
if (options.ContainsKey("json")) Console.WriteLine(JsonSerializer.Serialize(result, SemanticIndex.Json));
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"# {command}: {query}\n\nShowing {Math.Min(limit, Math.Max(0, all.Length - offset))}/{all.Length}." + (all.Length > limit ? " Truncated; narrow the query or continue with --offset." : ""));
|
||||
if (missingDescriptions.Length > 0) Console.WriteLine($"\nSemantic descriptions incomplete: {missingDescriptions.Length} modules. No match is not proof of absence.");
|
||||
foreach (var warning in index.Coverage.Distinct().Take(12)) Console.WriteLine("\n> " + warning);
|
||||
foreach (var item in all.Skip(offset).Take(limit)) Render(item);
|
||||
}
|
||||
return command == "check" && index.Findings.Any(f => f.Severity == "error") ? 1 : 0;
|
||||
}
|
||||
catch (Exception e) when (e is ArgumentException or IOException or JsonException or InvalidOperationException or FormatException or OverflowException)
|
||||
{
|
||||
if (args.Contains("--json")) Console.WriteLine(JsonSerializer.Serialize(new { success = false, code = "SHRINK-INSPECT-INPUT", error = e.Message }, SemanticIndex.Json));
|
||||
else Console.Error.WriteLine("SHRINK-INSPECT-INPUT: " + e.Message);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<Relation> Walk(Index index, string query, int depth, string? module)
|
||||
{
|
||||
var ids = index.Symbols.Values.Where(s =>
|
||||
(s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Location.File.Contains(query, StringComparison.OrdinalIgnoreCase))).Select(s => s.Id).ToHashSet();
|
||||
foreach (var edge in index.Relations)
|
||||
foreach (var endpoint in new[] { edge.From, edge.To })
|
||||
if (endpoint.Contains(query, StringComparison.OrdinalIgnoreCase)) ids.Add(endpoint);
|
||||
var seen = new HashSet<Relation>();
|
||||
for (var step = 0; step < depth; step++)
|
||||
{
|
||||
var next = new HashSet<string>();
|
||||
foreach (var edge in index.Relations.Where(r => ids.Contains(r.From) || ids.Contains(r.To)))
|
||||
if (seen.Add(edge)) { // External utility methods do not connect every unrelated caller into one flow.
|
||||
foreach (var endpoint in new[] { edge.From, edge.To })
|
||||
if (index.Symbols.ContainsKey(endpoint) || !endpoint.StartsWith("M:", StringComparison.Ordinal)) next.Add(endpoint);
|
||||
if (module == null || index.ModuleFor(edge.Location.File).Contains(module, StringComparison.OrdinalIgnoreCase) ||
|
||||
new[] { edge.From, edge.To }.Any(id => index.Symbols.TryGetValue(id, out var symbol) && symbol.Module.Contains(module, StringComparison.OrdinalIgnoreCase))) yield return edge; }
|
||||
ids.UnionWith(next);
|
||||
}
|
||||
}
|
||||
|
||||
public static void AttachSnapshot(Index index, string path)
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var root = document.RootElement;
|
||||
var identity = root.TryGetProperty("BuildId", out var b) ? b.GetString() : null;
|
||||
if (string.IsNullOrEmpty(index.BuildId) || identity != index.BuildId)
|
||||
{ index.Coverage.Add("Runtime snapshot build mismatch or unverified source identity. Runtime relations were NOT merged."); return; }
|
||||
if (root.TryGetProperty("Coverage", out var coverage))
|
||||
foreach (var item in coverage.EnumerateArray()) index.Coverage.Add("Snapshot: " + item.GetString());
|
||||
if (root.TryGetProperty("State", out var state))
|
||||
foreach (var scope in state.EnumerateObject())
|
||||
foreach (var field in scope.Value.EnumerateObject())
|
||||
if (field.Value.ValueKind == JsonValueKind.Array)
|
||||
foreach (var entry in field.Value.EnumerateArray()) index.RuntimeStates.Add(new(scope.Name, field.Name, entry.Clone(), new(Path.GetFullPath(path), 1)));
|
||||
else index.RuntimeStates.Add(new(scope.Name, field.Name, field.Value.Clone(), new(Path.GetFullPath(path), 1)));
|
||||
if (!root.TryGetProperty("Relations", out var relations)) return;
|
||||
foreach (var relation in relations.EnumerateArray())
|
||||
{
|
||||
var from = relation.GetProperty("From").GetString()!;
|
||||
var to = relation.GetProperty("To").GetString()!;
|
||||
var kind = relation.GetProperty("Kind").GetString()!;
|
||||
var scope = relation.TryGetProperty("Scope", out var scopeElement) ? scopeElement.GetString() : "";
|
||||
index.Relations.Add(new(from, to, kind + " [" + scope + "]", "runtime-bound", new(Path.GetFullPath(path), 1)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void Render(object item)
|
||||
{
|
||||
string Link(SourceLocation loc) => $"[{Path.GetFileName(loc.File)}:{loc.Line}]({loc.File.Replace('\\', '/')}:{loc.Line})";
|
||||
Console.WriteLine(item switch {
|
||||
Capability c => $"\n- **{c.Module}** — {c.Summary}\n Match: {c.MatchReason}\n Limits: {c.Limits}\n Extension: {c.Extension}\n Evidence: {c.Evidence}; {Link(c.Location)}",
|
||||
ModuleInfo m => $"\n- **{m.Name} {m.Version}** — {m.Root}\n Dependencies: {string.Join(", ", m.Dependencies)}\n Validation: {string.Join(", ", m.Validation)}",
|
||||
SymbolInfo s => $"\n- `{s.Id}` ({s.Module}) — {Link(s.Location)}",
|
||||
Relation r => $"\n- `{r.From}` → `{r.To}` [{r.Kind}; {r.Evidence}] — {Link(r.Location)}",
|
||||
RuntimeState s => $"\n- Runtime `{s.Scope}/{s.Name}`: {s.Value} — {Link(s.Location)}",
|
||||
Finding f => $"\n- {f.Severity} **{f.Code}** {f.Message} — {Link(f.Location)}",
|
||||
_ => "\n" + JsonSerializer.Serialize(item)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>shrink</ToolCommandName>
|
||||
<PackageId>ShrinkSDK.Inspect</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Description>Bounded, evidence-based ShrinkSDK capability and code navigation.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Assets/Modules/ShrinkShared.CodeGen/DotNet~/ShrinkSDK.CodeGen.Analyzers/ShrinkSDK.CodeGen.Analyzers.csproj" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user