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 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(StringComparer.Ordinal); var words = new List(); 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().ToArray(), "capabilities" => ModuleCatalog.Search(index, query, moduleFilter).Cast().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().Concat(Walk(index, query, 1, moduleFilter)).ToArray(), "flow" or "impact" => Walk(index, query, depth, moduleFilter).Cast().Concat(index.RuntimeStates.Where(s => s.Scope.Contains(query, StringComparison.OrdinalIgnoreCase) || s.Value.ToString().Contains(stateQuery, StringComparison.OrdinalIgnoreCase))).ToArray(), "check" => index.Findings.Distinct().Cast().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().Any(r => index.ModuleFor(r.Location.File) == m.Name)).SelectMany(m => m.Validation).Distinct().Take(8).ToArray() : Array.Empty(), 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 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(); for (var step = 0; step < depth; step++) { var next = new HashSet(); 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) }); } }