using System.Text.Json; using System.Text.RegularExpressions; namespace ShrinkSDK.Inspect; public static class ModuleCatalog { private static readonly HashSet Excluded = new(StringComparer.OrdinalIgnoreCase) { ".git", "Library", "Temp", "obj", "bin", "Logs", "Builds", "Artifacts", "node_modules", "Development~", "Samples~" }; public static IEnumerable 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 { 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("", 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(), new HashSet()); void Visit(ModuleInfo current, HashSet path, HashSet 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 output) { var lines = File.ReadAllLines(path); var start = Array.IndexOf(lines, ""); var end = Array.IndexOf(lines, ""); 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 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() ?? "" : ""; }