{"TotalCount":12,"Files":[{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetBrowserNavigator.cs","FileName":"AssetBrowserNavigator.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EFocuses an editor-known asset in its Asset Browser view.\u003C/summary\u003E\r\npublic static class AssetBrowserNavigator\r\n{\r\n    /// \u003Csummary\u003EAttempts to focus an asset by logical path and falls back to highlighting the path.\u003C/summary\u003E\r\n    public static void FocusAsset(string path)\r\n    {\r\n        if(string.IsNullOrWhiteSpace(path)) return;\r\n        var asset = AssetSystem.All.FirstOrDefault(item =\u003E\r\n            item != null \u0026\u0026 string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase));\r\n        if(asset == null)\r\n        {\r\n            EditorEvent.Run(\u0022assetsystem.highlight\u0022, path);\r\n            Log.Warning($\u0022Asset Doctor could not select \u0027{path}\u0027; sent an Asset Browser highlight instead.\u0022);\r\n            return;\r\n        }\r\n\r\n        var browser = AssetBrowser.Get();\r\n        var assetBrowser = browser?.GetBrowser(asset);\r\n        if(assetBrowser == null)\r\n        {\r\n            Log.Warning($\u0022Asset Doctor could not locate an Asset Browser view for \u0027{path}\u0027.\u0022);\r\n            return;\r\n        }\r\n\r\n        assetBrowser.FocusOnAsset(asset, true);\r\n    }\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetDoctorReportExporter.cs","FileName":"AssetDoctorReportExporter.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EWrites timestamped Markdown, text, and JSON reports for completed heuristic scans.\u003C/summary\u003E\r\npublic static class AssetDoctorReportExporter\r\n{\r\n    /// \u003Csummary\u003EExports all report formats with one timestamp and attempts to remove partial final files on failure.\u003C/summary\u003E\r\n    public static IReadOnlyList\u003Cstring\u003E Export(string directory, string projectName, IReadOnlyCollection\u003CFinding\u003E findings)\r\n    {\r\n        if(string.IsNullOrWhiteSpace(directory)) throw new ArgumentException(\u0022A report directory is required.\u0022, nameof(directory));\r\n        if(findings == null) throw new ArgumentNullException(nameof(findings));\r\n        Directory.CreateDirectory(directory);\r\n        var generatedAt = DateTimeOffset.UtcNow;\r\n        var stamp = generatedAt.ToString(\u0022yyyyMMdd-HHmmss-fff\u0022, CultureInfo.InvariantCulture);\r\n        var safeProject = SanitizeFileName(string.IsNullOrWhiteSpace(projectName) ? \u0022project\u0022 : projectName);\r\n        var unique = Guid.NewGuid().ToString(\u0022N\u0022)[..8];\r\n        var prefix = $\u0022asset_doctor_{safeProject}_{stamp}_{unique}\u0022;\r\n        var ordered = findings.OrderByDescending(x =\u003E Rank(x.Severity)).ThenBy(x =\u003E x.RuleId, StringComparer.Ordinal).ThenBy(x =\u003E x.SourcePath, StringComparer.Ordinal).ToArray();\r\n        var finals = new[] { Path.Combine(directory, prefix \u002B \u0022.md\u0022), Path.Combine(directory, prefix \u002B \u0022.txt\u0022), Path.Combine(directory, prefix \u002B \u0022.json\u0022) };\r\n        var temps = finals.Select(path =\u003E path \u002B \u0022.tmp-\u0022 \u002B Guid.NewGuid().ToString(\u0022N\u0022)).ToArray();\r\n        var moved = new List\u003Cstring\u003E();\r\n        try\r\n        {\r\n            WriteMarkdown(temps[0], ordered, generatedAt);\r\n            WriteText(temps[1], ordered, generatedAt);\r\n            WriteJson(temps[2], ordered, generatedAt);\r\n            for(var index = 0; index \u003C finals.Length; index\u002B\u002B)\r\n            {\r\n                File.Move(temps[index], finals[index], false);\r\n                moved.Add(finals[index]);\r\n            }\r\n            return finals;\r\n        }\r\n        catch\r\n        {\r\n            foreach(var path in moved) TryDelete(path);\r\n            throw;\r\n        }\r\n        finally\r\n        {\r\n            foreach(var path in temps) TryDelete(path);\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003EWrites a Markdown report without building an additional full report string in memory.\u003C/summary\u003E\r\n    private static void WriteMarkdown(string path, IReadOnlyList\u003CFinding\u003E findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\u0022# Asset Doctor Report\u0022);\r\n        writer.WriteLine();\r\n        writer.WriteLine($\u0022Generated: {generatedAt:O}\u0022);\r\n        writer.WriteLine(\u0022Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.\u0022);\r\n        writer.WriteLine($\u0022Issues: {findings.Count}\u0022);\r\n        writer.WriteLine();\r\n        foreach(var finding in findings)\r\n        {\r\n            writer.WriteLine($\u0022## {EscapeMarkdown(finding.RuleId)} \u00B7 {finding.Severity}\u0022);\r\n            writer.WriteLine();\r\n            writer.WriteLine(EscapeMarkdown(finding.Message));\r\n            writer.WriteLine($\u0022- Source: {Code(finding.SourcePath)}\u0022);\r\n            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($\u0022- Reference: {Code(finding.ReferencedPath)}\u0022);\r\n            if(finding.Line.HasValue) writer.WriteLine($\u0022- Line: {finding.Line.Value}\u0022);\r\n            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($\u0022- Details: {Code(finding.Details)}\u0022);\r\n            writer.WriteLine();\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003EWrites a plain-text report without building an additional full report string in memory.\u003C/summary\u003E\r\n    private static void WriteText(string path, IReadOnlyList\u003CFinding\u003E findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\u0022ASSET DOCTOR REPORT\u0022);\r\n        writer.WriteLine($\u0022Generated: {generatedAt:O}\u0022);\r\n        writer.WriteLine(\u0022Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.\u0022);\r\n        writer.WriteLine($\u0022Issues: {findings.Count}\u0022);\r\n        writer.WriteLine();\r\n        foreach(var finding in findings)\r\n        {\r\n            writer.WriteLine($\u0022{finding.RuleId} \u00B7 {finding.Severity} \u00B7 {Plain(finding.Message)}\u0022);\r\n            writer.WriteLine($\u0022Source: {Plain(finding.SourcePath)}\u0022);\r\n            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($\u0022Reference: {Plain(finding.ReferencedPath)}\u0022);\r\n            if(finding.Line.HasValue) writer.WriteLine($\u0022Line: {finding.Line.Value}\u0022);\r\n            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($\u0022Details: {Plain(finding.Details)}\u0022);\r\n            writer.WriteLine();\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003EWrites a machine-readable JSON report without requiring external serializer packages.\u003C/summary\u003E\r\n    private static void WriteJson(string path, IReadOnlyList\u003CFinding\u003E findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\u0022{\u0022);\r\n        writer.WriteLine($\u0022  \\\u0022generatedAt\\\u0022: {Json(generatedAt.ToString(\u0022O\u0022, CultureInfo.InvariantCulture))},\u0022);\r\n        writer.WriteLine(\u0022  \\\u0022detectionMode\\\u0022: \\\u0022heuristic quoted-path scan; not a complete dependency graph\\\u0022,\u0022);\r\n        writer.WriteLine(\u0022  \\\u0022findings\\\u0022: [\u0022);\r\n        for(var index = 0; index \u003C findings.Count; index\u002B\u002B)\r\n        {\r\n            var finding = findings[index];\r\n            writer.Write(\u0022    { \\\u0022ruleId\\\u0022: \u0022); writer.Write(Json(finding.RuleId));\r\n            writer.Write(\u0022, \\\u0022severity\\\u0022: \u0022); writer.Write(Json(finding.Severity.ToString()));\r\n            writer.Write(\u0022, \\\u0022message\\\u0022: \u0022); writer.Write(Json(finding.Message));\r\n            writer.Write(\u0022, \\\u0022sourcePath\\\u0022: \u0022); writer.Write(Json(finding.SourcePath));\r\n            writer.Write(\u0022, \\\u0022referencedPath\\\u0022: \u0022); writer.Write(Json(finding.ReferencedPath));\r\n            writer.Write(\u0022, \\\u0022line\\\u0022: \u0022); writer.Write(finding.Line?.ToString(CultureInfo.InvariantCulture) ?? \u0022null\u0022);\r\n            writer.Write(\u0022, \\\u0022details\\\u0022: \u0022); writer.Write(Json(finding.Details));\r\n            writer.Write(\u0022 }\u0022);\r\n            if(index \u002B 1 \u003C findings.Count) writer.Write(\u0027,\u0027);\r\n            writer.WriteLine();\r\n        }\r\n        writer.WriteLine(\u0022  ]\u0022);\r\n        writer.WriteLine(\u0022}\u0022);\r\n    }\r\n\r\n    /// \u003Csummary\u003ECreates a UTF-8 writer for one temporary report.\u003C/summary\u003E\r\n    private static StreamWriter CreateWriter(string path) =\u003E new(path, false, new UTF8Encoding(false));\r\n\r\n    /// \u003Csummary\u003EEscapes a JSON string including control characters.\u003C/summary\u003E\r\n    private static string Json(string? value)\r\n    {\r\n        if(value == null) return \u0022null\u0022;\r\n        var builder = new StringBuilder(value.Length \u002B 2).Append(\u0027\u0022\u0027);\r\n        foreach(var character in value)\r\n        {\r\n            switch(character)\r\n            {\r\n                case \u0027\\\\\u0027: builder.Append(\u0022\\\\\\\\\u0022); break;\r\n                case \u0027\u0022\u0027: builder.Append(\u0022\\\\\\\u0022\u0022); break;\r\n                case \u0027\\b\u0027: builder.Append(\u0022\\\\b\u0022); break;\r\n                case \u0027\\f\u0027: builder.Append(\u0022\\\\f\u0022); break;\r\n                case \u0027\\n\u0027: builder.Append(\u0022\\\\n\u0022); break;\r\n                case \u0027\\r\u0027: builder.Append(\u0022\\\\r\u0022); break;\r\n                case \u0027\\t\u0027: builder.Append(\u0022\\\\t\u0022); break;\r\n                default:\r\n                    if(character \u003C \u0027 \u0027) builder.Append($\u0022\\\\u{(int)character:X4}\u0022);\r\n                    else builder.Append(character);\r\n                    break;\r\n            }\r\n        }\r\n        return builder.Append(\u0027\u0022\u0027).ToString();\r\n    }\r\n\r\n    /// \u003Csummary\u003EEscapes Markdown characters that could alter report structure.\u003C/summary\u003E\r\n    private static string EscapeMarkdown(string? value) =\u003E Plain(value).Replace(\u0022\\\\\u0022, \u0022\\\\\\\\\u0022).Replace(\u0022\u0060\u0022, \u0022\\\\\u0060\u0022).Replace(\u0022*\u0022, \u0022\\\\*\u0022).Replace(\u0022_\u0022, \u0022\\\\_\u0022).Replace(\u0022[\u0022, \u0022\\\\[\u0022).Replace(\u0022]\u0022, \u0022\\\\]\u0022).Replace(\u0022#\u0022, \u0022\\\\#\u0022).Replace(\u0022|\u0022, \u0022\\\\|\u0022).Replace(\u0022!\u0022, \u0022\\\\!\u0022).Replace(\u0022~\u0022, \u0022\\\\~\u0022).Replace(\u0022\u003C\u0022, \u0022\u0026lt;\u0022).Replace(\u0022\u003E\u0022, \u0022\u0026gt;\u0022);\r\n\r\n    /// \u003Csummary\u003EUses a variable-length inline-code delimiter so embedded backticks remain literal.\u003C/summary\u003E\r\n    private static string Code(string? value)\r\n    {\r\n        var text = Plain(value);\r\n        var fence = \u0022\u0060\u0022;\r\n        while(text.Contains(fence, StringComparison.Ordinal)) fence \u002B= \u0022\u0060\u0022;\r\n        return fence \u002B text \u002B fence;\r\n    }\r\n\r\n    /// \u003Csummary\u003ERenders selected control and directional characters visibly to reduce report spoofing.\u003C/summary\u003E\r\n    private static string Plain(string? value)\r\n    {\r\n        if(string.IsNullOrEmpty(value)) return string.Empty;\r\n        var builder = new StringBuilder(value.Length);\r\n        foreach(var character in value) builder.Append(char.IsControl(character) || character == \u0027\\u202E\u0027 ? $\u0022\\\\u{(int)character:X4}\u0022 : character);\r\n        return builder.ToString();\r\n    }\r\n\r\n    /// \u003Csummary\u003EReplaces filename characters that are invalid on the current platform.\u003C/summary\u003E\r\n    private static string SanitizeFileName(string value) =\u003E string.Concat(value.Select(character =\u003E Path.GetInvalidFileNameChars().Contains(character) ? \u0027_\u0027 : character));\r\n\r\n    /// \u003Csummary\u003EDeletes temporary or rolled-back files without masking the primary export error.\u003C/summary\u003E\r\n    private static void TryDelete(string path)\r\n    {\r\n        try { if(File.Exists(path)) File.Delete(path); }\r\n        catch { }\r\n    }\r\n\r\n    /// \u003Csummary\u003EReturns a deterministic severity sort rank.\u003C/summary\u003E\r\n    private static int Rank(FindingSeverity severity) =\u003E severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetContextMenuActions.cs","FileName":"AssetContextMenuActions.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EAdds Asset Doctor direct-link actions to the Asset Browser context menu.\u003C/summary\u003E\r\npublic static class AssetContextMenuActions\r\n{\r\n    /// \u003Csummary\u003EStores the nested menu path for reverse dependency lookup.\u003C/summary\u003E\r\n    private static readonly string[] FindDependantsMenuPath = { \u0022Asset Doctor\u0022, \u0022Find Assets Using This\u0022 };\r\n    /// \u003Csummary\u003EStores the nested menu path for forward dependency lookup.\u003C/summary\u003E\r\n    private static readonly string[] FindReferencesMenuPath = { \u0022Asset Doctor\u0022, \u0022Find Assets Used By This\u0022 };\r\n\r\n    /// \u003Csummary\u003ERegisters direct-link actions when exactly one valid Asset Browser entry is selected.\u003C/summary\u003E\r\n    [Event(\u0022asset.contextmenu\u0022)]\r\n    private static void OnAssetContextMenu(AssetContextMenu context)\r\n    {\r\n        if(context.SelectedList == null || context.SelectedList.Count != 1) return;\r\n        var asset = context.SelectedList[0].Asset;\r\n        if(asset == null || string.IsNullOrWhiteSpace(asset.Path)) return;\r\n        var added = false;\r\n\r\n        context.Menu.AboutToShow \u002B= () =\u003E\r\n        {\r\n            if(added) return;\r\n            added = true;\r\n\r\n            context.Menu.AddSeparator();\r\n            context.Menu.AddOption(\r\n                FindDependantsMenuPath,\r\n                \u0022manage_search\u0022,\r\n                () =\u003E new AssetLinksWindow(asset, true),\r\n                \u0022Show assets that directly use this asset\u0022);\r\n\r\n            context.Menu.AddOption(\r\n                FindReferencesMenuPath,\r\n                \u0022account_tree\u0022,\r\n                () =\u003E new AssetLinksWindow(asset, false),\r\n                \u0022Show assets directly used by this asset\u0022);\r\n        };\r\n    }\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetDoctorCore.cs","FileName":"AssetDoctorCore.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Text;\r\nusing System.Threading;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EDescribes the severity assigned to a validation result.\u003C/summary\u003E\r\npublic enum FindingSeverity { Info, Warning, Error }\r\n\r\n/// \u003Csummary\u003ERepresents one diagnostic produced by a scan.\u003C/summary\u003E\r\npublic sealed record Finding(string RuleId, FindingSeverity Severity, string Message, string SourcePath, string? ReferencedPath = null, int? Line = null, string? Details = null);\r\n\r\n/// \u003Csummary\u003EStores shared asset-path constants and normalization helpers.\u003C/summary\u003E\r\npublic static class AssetPathRules\r\n{\r\n    /// \u003Csummary\u003ELimits an individual quoted candidate to prevent pathological scans.\u003C/summary\u003E\r\n    public const int MaxReferenceLength = 4096;\r\n    /// \u003Csummary\u003ELimits extracted references from one source file to protect scan memory.\u003C/summary\u003E\r\n    public const int MaxReferencesPerSourceFile = 10_000;\r\n    /// \u003Csummary\u003ELimits physical source-file bytes before allocating text in memory.\u003C/summary\u003E\r\n    public const long MaxTextFileBytes = 4_000_000;\r\n    /// \u003Csummary\u003ELimits decoded source characters after a successful read.\u003C/summary\u003E\r\n    public const int MaxTextCharacters = 4_000_000;\r\n    /// \u003Csummary\u003ELists source formats whose quoted values are currently scanned heuristically.\u003C/summary\u003E\r\n    public static readonly string[] TextAssetExtensions = { \u0022.scene\u0022, \u0022.prefab\u0022, \u0022.vmdl\u0022, \u0022.vmat\u0022, \u0022.vtex\u0022, \u0022.sound\u0022, \u0022.surface\u0022, \u0022.clothing\u0022, \u0022.decal\u0022, \u0022.vmap\u0022, \u0022.vfx\u0022, \u0022.vanmgrph\u0022, \u0022.vpost\u0022, \u0022.shader\u0022, \u0022.shdrgrph\u0022, \u0022.vpcf\u0022, \u0022.json\u0022 };\r\n    /// \u003Csummary\u003ELists referenced asset extensions recognized inside quoted source values.\u003C/summary\u003E\r\n    public static readonly string[] ReferenceExtensions = { \u0022.scene\u0022, \u0022.prefab\u0022, \u0022.vmdl\u0022, \u0022.vmat\u0022, \u0022.vtex\u0022, \u0022.sound\u0022, \u0022.surface\u0022, \u0022.clothing\u0022, \u0022.decal\u0022, \u0022.vmap\u0022, \u0022.vfx\u0022, \u0022.vanmgrph\u0022, \u0022.vpost\u0022, \u0022.shader\u0022, \u0022.shdrgrph\u0022, \u0022.vpcf\u0022, \u0022.png\u0022, \u0022.jpg\u0022, \u0022.jpeg\u0022, \u0022.tga\u0022, \u0022.fbx\u0022, \u0022.json\u0022 };\r\n    /// \u003Csummary\u003EReturns whether a path ends in one of the supplied extensions.\u003C/summary\u003E\r\n    public static bool HasAnyExtension(string path, IReadOnlyList\u003Cstring\u003E extensions)\r\n    {\r\n        if(string.IsNullOrEmpty(path)) return false;\r\n        for(var index = 0; index \u003C extensions.Count; index\u002B\u002B) if(path.EndsWith(extensions[index], StringComparison.OrdinalIgnoreCase)) return true;\r\n        return false;\r\n    }\r\n    /// \u003Csummary\u003EConverts one or more Windows separators without removing unsafe whitespace.\u003C/summary\u003E\r\n    public static string NormalizeSeparators(string path) =\u003E (path ?? string.Empty).Replace(\u0027\\\\\u0027, \u0027/\u0027);\r\n    /// \u003Csummary\u003EReturns whether references in this source format should use JSON-style escape decoding.\u003C/summary\u003E\r\n    public static bool UsesJsonEscapes(string path) =\u003E path.EndsWith(\u0022.json\u0022, StringComparison.OrdinalIgnoreCase) || path.EndsWith(\u0022.prefab\u0022, StringComparison.OrdinalIgnoreCase) || path.EndsWith(\u0022.scene\u0022, StringComparison.OrdinalIgnoreCase);\r\n}\r\n\r\n/// \u003Csummary\u003ERepresents one extracted asset path and its one-based source line.\u003C/summary\u003E\r\npublic sealed record AssetReference(string Path, int Line);\r\n\r\n/// \u003Csummary\u003EContains bounded reference extraction output and indicates whether the per-file limit was reached.\u003C/summary\u003E\r\npublic sealed record ReferenceExtractionResult(IReadOnlyList\u003CAssetReference\u003E References, bool IsTruncated);\r\n\r\n/// \u003Csummary\u003EExtracts quoted asset paths with bounded linear-time scanning.\u003C/summary\u003E\r\npublic static class ReferenceExtractor\r\n{\r\n    /// \u003Csummary\u003EExtracts recognized references from text; output is heuristic because only quoted values are examined.\u003C/summary\u003E\r\n    public static ReferenceExtractionResult Extract(string? text, bool decodeJsonEscapes, CancellationToken token = default)\r\n    {\r\n        var results = new List\u003CAssetReference\u003E();\r\n        if(string.IsNullOrEmpty(text)) return new ReferenceExtractionResult(results, false);\r\n        var line = 1;\r\n        for(var index = 0; index \u003C text.Length; index\u002B\u002B)\r\n        {\r\n            if((index \u0026 0xFFF) == 0) token.ThrowIfCancellationRequested();\r\n            if(text[index] == \u0027\\n\u0027) { line\u002B\u002B; continue; }\r\n            var quote = text[index];\r\n            if(quote != \u0027\\\u0027\u0027 \u0026\u0026 quote != \u0027\u0022\u0027) continue;\r\n            var startLine = line;\r\n            var start = \u002B\u002Bindex;\r\n            var escaped = false;\r\n            var tooLong = false;\r\n            var closed = false;\r\n            while(index \u003C text.Length)\r\n            {\r\n                if((index \u0026 0xFFF) == 0) token.ThrowIfCancellationRequested();\r\n                var character = text[index];\r\n                if(character == \u0027\\n\u0027) line\u002B\u002B;\r\n                if(character == quote \u0026\u0026 !escaped) { closed = true; break; }\r\n                if(index - start \u003E= AssetPathRules.MaxReferenceLength) tooLong = true;\r\n                escaped = character == \u0027\\\\\u0027 ? !escaped : false;\r\n                index\u002B\u002B;\r\n            }\r\n            if(!closed || tooLong) continue;\r\n            var raw = text.Substring(start, index - start);\r\n            if(!TryDecodeEscapes(raw, decodeJsonEscapes, out var decoded)) continue;\r\n            var normalized = AssetPathRules.NormalizeSeparators(decoded);\r\n            if(normalized.Length == 0 || !AssetPathRules.HasAnyExtension(normalized, AssetPathRules.ReferenceExtensions)) continue;\r\n            results.Add(new AssetReference(normalized, startLine));\r\n            if(results.Count \u003E= AssetPathRules.MaxReferencesPerSourceFile)\r\n                return new ReferenceExtractionResult(results, true);\r\n        }\r\n        return new ReferenceExtractionResult(results, false);\r\n    }\r\n\r\n    /// \u003Csummary\u003EDecodes JSON escapes only for JSON-like source formats and preserves ordinary backslash paths otherwise.\u003C/summary\u003E\r\n    private static bool TryDecodeEscapes(string raw, bool decodeJsonEscapes, out string value)\r\n    {\r\n        if(!decodeJsonEscapes) { value = raw; return true; }\r\n        var builder = new StringBuilder(raw.Length);\r\n        for(var index = 0; index \u003C raw.Length; index\u002B\u002B)\r\n        {\r\n            var character = raw[index];\r\n            if(character != \u0027\\\\\u0027) { builder.Append(character); continue; }\r\n            if(\u002B\u002Bindex \u003E= raw.Length) { value = string.Empty; return false; }\r\n            switch(raw[index])\r\n            {\r\n                case \u0027\u0022\u0027: builder.Append(\u0027\u0022\u0027); break;\r\n                case \u0027\\\\\u0027: builder.Append(\u0027\\\\\u0027); break;\r\n                case \u0027/\u0027: builder.Append(\u0027/\u0027); break;\r\n                case \u0027b\u0027: builder.Append(\u0027\\b\u0027); break;\r\n                case \u0027f\u0027: builder.Append(\u0027\\f\u0027); break;\r\n                case \u0027n\u0027: builder.Append(\u0027\\n\u0027); break;\r\n                case \u0027r\u0027: builder.Append(\u0027\\r\u0027); break;\r\n                case \u0027t\u0027: builder.Append(\u0027\\t\u0027); break;\r\n                case \u0027u\u0027 when index \u002B 4 \u003C raw.Length:\r\n                    var hex = raw.Substring(index \u002B 1, 4);\r\n                    if(!ushort.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var code)) { value = string.Empty; return false; }\r\n                    builder.Append((char)code); index \u002B= 4; break;\r\n                default: value = string.Empty; return false;\r\n            }\r\n        }\r\n        value = builder.ToString();\r\n        return true;\r\n    }\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":".obj/__compiler_extra.cs","FileName":"__compiler_extra.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":337738,"Code":"global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonTitle\u0022, \u0022Asset Doctor\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonIdent\u0022, \u0022assetdoctor\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022OrgIdent\u0022, \u0022mikekotys\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022Ident\u0022, \u0022mikekotys.assetdoctor\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineVersion\u0022, \u002228\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineMinorVersion\u0022, \u00221\u0022 )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \u0022.NETCoreApp,Version=v9.0\u0022, FrameworkDisplayName = \u0022.NET 9.0\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022CompileTime\u0022, \u00222026-07-31T09:27:02.2172776Z\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\u00220.0.113.0\u0022)]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\u00220.0.113.0\u0022)]"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/Assembly.cs","FileName":"Assembly.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"// Shared editor namespaces for this single s\u0026box editor-package assembly.\nglobal using Sandbox;\nglobal using Editor;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetPathValidator.cs","FileName":"AssetPathValidator.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.Text;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EValidates that extracted references are safe portable asset paths.\u003C/summary\u003E\r\npublic static class AssetPathValidator\r\n{\r\n    /// \u003Csummary\u003EReturns the highest-priority finding for a reference, or null when no issue is detected.\u003C/summary\u003E\r\n    public static Finding? Validate(string sourcePath, string? referencedPath, int? line = null)\r\n    {\r\n        try\r\n        {\r\n            if(string.IsNullOrWhiteSpace(referencedPath)) return null;\r\n            if(!string.Equals(referencedPath, referencedPath.Trim(), StringComparison.Ordinal)) return New(\u0022AD109\u0022, FindingSeverity.Warning, \u0022Asset path has leading or trailing whitespace.\u0022, sourcePath, referencedPath, line);\r\n            var path = AssetPathRules.NormalizeSeparators(referencedPath);\r\n            foreach(var character in path) if(char.IsControl(character) || character == \u0027\\u202E\u0027) return New(\u0022AD101\u0022, FindingSeverity.Error, \u0022Asset path contains unsafe control or directional characters.\u0022, sourcePath, referencedPath, line);\r\n            if(path.StartsWith(\u0022mount://\u0022, StringComparison.OrdinalIgnoreCase)) return New(\u0022AD102\u0022, FindingSeverity.Error, \u0022Mounted assets cannot be included in a published package.\u0022, sourcePath, referencedPath, line);\r\n            if(path.Contains(\u0022://\u0022, StringComparison.Ordinal)) return New(\u0022AD106\u0022, FindingSeverity.Error, \u0022External URI used where a project-relative asset path is expected.\u0022, sourcePath, referencedPath, line);\r\n            var drivePath = path.Length \u003E= 3 \u0026\u0026 ((path[0] is \u003E= \u0027A\u0027 and \u003C= \u0027Z\u0027) || (path[0] is \u003E= \u0027a\u0027 and \u003C= \u0027z\u0027)) \u0026\u0026 path[1] == \u0027:\u0027 \u0026\u0026 path[2] == \u0027/\u0027;\r\n            if(path.StartsWith(\u0022/\u0022, StringComparison.Ordinal) || drivePath || path.IndexOf(\u0027:\u0027) \u003E= 0) return New(\u0022AD107\u0022, FindingSeverity.Error, \u0022Absolute or drive-relative asset paths are not portable.\u0022, sourcePath, referencedPath, line);\r\n            var decoded = TryPercentDecode(path);\r\n            foreach(var segment in decoded.Split(\u0027/\u0027)) if(segment == \u0022..\u0022) return New(\u0022AD108\u0022, FindingSeverity.Error, \u0022Parent-directory traversal is not allowed in asset paths.\u0022, sourcePath, referencedPath, line);\r\n            if(path.StartsWith(\u0022./\u0022, StringComparison.Ordinal) || path.Contains(\u0022//\u0022, StringComparison.Ordinal) || path.Contains(\u0022/./\u0022, StringComparison.Ordinal) || !path.IsNormalized(NormalizationForm.FormC)) return New(\u0022AD109\u0022, FindingSeverity.Warning, \u0022Asset path is not canonical.\u0022, sourcePath, referencedPath, line);\r\n            return null;\r\n        }\r\n        catch(Exception exception) when(exception is ArgumentException || exception is UriFormatException)\r\n        {\r\n            return New(\u0022AD101\u0022, FindingSeverity.Error, \u0022Asset path contains invalid Unicode or encoding.\u0022, sourcePath, referencedPath ?? string.Empty, line);\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003EAttempts to decode percent escapes for traversal detection without changing the reported original path.\u003C/summary\u003E\r\n    private static string TryPercentDecode(string path)\r\n    {\r\n        try { return Uri.UnescapeDataString(path); }\r\n        catch(UriFormatException) { return path; }\r\n    }\r\n\r\n    /// \u003Csummary\u003ECreates a finding with consistent source-location metadata.\u003C/summary\u003E\r\n    private static Finding New(string ruleId, FindingSeverity severity, string message, string source, string reference, int? line) =\u003E new(ruleId, severity, message, source, reference, line);\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetLinksWindow.cs","FileName":"AssetLinksWindow.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\nusing System;\nusing System.Linq;\n\nnamespace AssetDoctor;\n\n/// \u003Csummary\u003EShows direct native s\u0026box references or dependants for one selected asset.\u003C/summary\u003E\npublic sealed class AssetLinksWindow : Widget\n{\n    /// \u003Csummary\u003ELimits synchronous row creation while retaining the true lookup count.\u003C/summary\u003E\n    private const int MaxRenderedAssets = 500;\n\n    /// \u003Csummary\u003ECreates and displays a compact direct-link inspector.\u003C/summary\u003E\n    public AssetLinksWindow(Asset asset, bool showDependants) : base(null)\n    {\n        if(asset == null) throw new ArgumentNullException(nameof(asset));\n        WindowTitle = showDependants ? \u0022Find Assets Using This\u0022 : \u0022Find Assets Used By This\u0022;\n        MinimumSize = new Vector2(420, 260);\n        Size = new Vector2(520, 340);\n        Layout = Layout.Column();\n        Layout.Margin = 6;\n        Layout.Spacing = 4;\n        Layout.Add(new Label(showDependants ? $\u0022Assets directly using: {asset.Path}\u0022 : $\u0022Assets directly used by: {asset.Path}\u0022, this));\n        Asset[] allLinks;\n        try\n        {\n            allLinks = (showDependants ? asset.GetDependants(false) : asset.GetReferences(false))?\n                .Where(x =\u003E x != null \u0026\u0026 !string.IsNullOrWhiteSpace(x.Path))\n                .GroupBy(x =\u003E x.Path, StringComparer.OrdinalIgnoreCase)\n                .Select(x =\u003E x.First())\n                .OrderBy(x =\u003E x.Path, StringComparer.OrdinalIgnoreCase)\n                .ToArray() ?? Array.Empty\u003CAsset\u003E();\n        }\n        catch(Exception exception)\n        {\n            Log.Error($\u0022Asset Doctor link lookup failed: {exception}\u0022);\n            Layout.Add(new Label($\u0022Lookup failed: {exception.GetType().Name}\u0022, this));\n            Show();\n            return;\n        }\n        var renderedLinks = allLinks.Take(MaxRenderedAssets).ToArray();\n        Layout.Add(new Label($\u0022Found {allLinks.Length} direct asset(s)\u0022, this));\n        var scroll = new ScrollArea(this);\n        Layout.Add(scroll);\n        var content = new Widget(null) { Layout = Layout.Column() };\n        content.Layout.Spacing = 2;\n        scroll.Canvas = content;\n        foreach(var link in renderedLinks)\n        {\n            var target = link;\n            var row = new FindingRow(content, target.Path, \u0022#1d2c3a\u0022, \u0022#3a6d8f\u0022, \u0022#c7e8ff\u0022);\n            row.Clicked \u002B= () =\u003E AssetBrowserNavigator.FocusAsset(target.Path);\n            content.Layout.Add(row);\n        }\n        if(allLinks.Length == 0) content.Layout.Add(new Label(\u0022No direct asset links found.\u0022, content));\n        if(allLinks.Length \u003E renderedLinks.Length) content.Layout.Add(new Label($\u0022\u2026 {allLinks.Length - renderedLinks.Length} more asset(s) were not rendered to protect Editor responsiveness.\u0022, content));\n        content.Layout.AddStretchCell();\n        Show();\n    }\n}\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetReferenceCycleDetector.cs","FileName":"AssetReferenceCycleDetector.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Threading;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EFinds direct-reference cycles using an iterative depth-first traversal.\u003C/summary\u003E\r\npublic static class AssetReferenceCycleDetector\r\n{\r\n    /// \u003Csummary\u003ERepresents the traversal state for one active graph node.\u003C/summary\u003E\r\n    private sealed class Frame\r\n    {\r\n        /// \u003Csummary\u003EInitializes a frame with the supplied dependency list.\u003C/summary\u003E\r\n        public Frame(string node, string[] dependencies) { Node = node; Dependencies = dependencies; }\r\n        /// \u003Csummary\u003EGets the active node path.\u003C/summary\u003E\r\n        public string Node { get; }\r\n        /// \u003Csummary\u003EGets dependencies to visit.\u003C/summary\u003E\r\n        public string[] Dependencies { get; }\r\n        /// \u003Csummary\u003EGets or sets the next dependency index.\u003C/summary\u003E\r\n        public int NextIndex { get; set; }\r\n    }\r\n\r\n    /// \u003Csummary\u003EFinds cycles in a graph and returns a single finding per back-edge path.\u003C/summary\u003E\r\n    public static List\u003CFinding\u003E Find(IReadOnlyDictionary\u003Cstring, HashSet\u003Cstring\u003E\u003E graph, CancellationToken token = default)\r\n    {\r\n        if(graph == null) throw new ArgumentNullException(nameof(graph));\r\n        var findings = new List\u003CFinding\u003E();\r\n        var states = new Dictionary\u003Cstring, byte\u003E(StringComparer.OrdinalIgnoreCase);\r\n        var active = new List\u003Cstring\u003E();\r\n        var positions = new Dictionary\u003Cstring, int\u003E(StringComparer.OrdinalIgnoreCase);\r\n        var frames = new List\u003CFrame\u003E();\r\n        var emitted = new HashSet\u003Cstring\u003E(StringComparer.OrdinalIgnoreCase);\r\n        foreach(var start in graph.Keys)\r\n        {\r\n            token.ThrowIfCancellationRequested();\r\n            if(states.ContainsKey(start)) continue;\r\n            Push(start);\r\n            while(frames.Count \u003E 0)\r\n            {\r\n                token.ThrowIfCancellationRequested();\r\n                var frame = frames[^1];\r\n                if(frame.NextIndex \u003E= frame.Dependencies.Length)\r\n                {\r\n                    frames.RemoveAt(frames.Count - 1); positions.Remove(frame.Node); active.RemoveAt(active.Count - 1); states[frame.Node] = 2; continue;\r\n                }\r\n                var dependency = frame.Dependencies[frame.NextIndex\u002B\u002B];\r\n                if(!states.TryGetValue(dependency, out var state)) { Push(dependency); continue; }\r\n                if(state != 1 || !positions.TryGetValue(dependency, out var startIndex)) continue;\r\n                var cycle = active.GetRange(startIndex, active.Count - startIndex);\r\n                var signature = string.Join(\u0022\\u001F\u0022, cycle);\r\n                if(!emitted.Add(signature)) continue;\r\n                cycle.Add(dependency);\r\n                findings.Add(new Finding(\u0022AD104\u0022, FindingSeverity.Error, \u0022Circular asset reference detected.\u0022, frame.Node, dependency, Details: string.Join(\u0022 \u2192 \u0022, cycle)));\r\n            }\r\n        }\r\n        return findings;\r\n\r\n        void Push(string node)\r\n        {\r\n            states[node] = 1; positions[node] = active.Count; active.Add(node);\r\n            var dependencies = graph.TryGetValue(node, out var values) \u0026\u0026 values != null ? new List\u003Cstring\u003E(values).ToArray() : Array.Empty\u003Cstring\u003E();\r\n            Array.Sort(dependencies, StringComparer.OrdinalIgnoreCase);\r\n            frames.Add(new Frame(node, dependencies));\r\n        }\r\n    }\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/ProjectAssetScope.cs","FileName":"ProjectAssetScope.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.IO;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003ELimits scans to physical source files beneath the current project\u0027s Assets directory.\u003C/summary\u003E\r\npublic sealed class ProjectAssetScope\r\n{\r\n    /// \u003Csummary\u003EStores the normalized project Assets directory.\u003C/summary\u003E\r\n    private readonly string _assetsRoot;\r\n\r\n    /// \u003Csummary\u003EStores the platform-selected path comparison mode.\u003C/summary\u003E\r\n    private readonly StringComparison _pathComparison;\r\n\r\n    /// \u003Csummary\u003ECreates a scope for the active project, or returns null when its Assets path is unavailable or invalid.\u003C/summary\u003E\r\n    public static ProjectAssetScope? TryCreate()\r\n    {\r\n        var assetsPath = Project.Current?.GetAssetsPath();\r\n        if(string.IsNullOrWhiteSpace(assetsPath)) return null;\r\n        try { return new ProjectAssetScope(assetsPath); }\r\n        catch(Exception exception) { Log.Warning($\u0022Asset Doctor could not resolve project Assets path: {exception.GetType().Name}\u0022); return null; }\r\n    }\r\n\r\n    /// \u003Csummary\u003EInitializes a normalized project asset scope.\u003C/summary\u003E\r\n    private ProjectAssetScope(string assetsPath)\r\n    {\r\n        _assetsRoot = NormalizeDirectory(assetsPath);\r\n        _pathComparison = Path.DirectorySeparatorChar == \u0027\\\\\u0027 ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;\r\n    }\r\n\r\n    /// \u003Csummary\u003EReturns whether an editor asset belongs to the current project\u0027s source Assets directory.\u003C/summary\u003E\r\n    public bool Contains(Editor.Asset? asset)\r\n    {\r\n        if(asset == null || !asset.HasSourceFile || string.IsNullOrWhiteSpace(asset.AbsolutePath)) return false;\r\n        try { return Path.GetFullPath(asset.AbsolutePath).StartsWith(_assetsRoot, _pathComparison); }\r\n        catch(Exception exception) { Log.Warning($\u0022Asset Doctor could not scope \u0027{asset.Path}\u0027: {exception.GetType().Name}\u0022); return false; }\r\n    }\r\n\r\n    /// \u003Csummary\u003ENormalizes a directory with one trailing separator for safe prefix matching.\u003C/summary\u003E\r\n    private static string NormalizeDirectory(string path)\r\n    {\r\n        var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);\r\n        return fullPath \u002B Path.DirectorySeparatorChar;\r\n    }\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/AssetDoctorWindow.cs","FileName":"AssetDoctorWindow.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EProvides the dockable UI for heuristic current-project asset diagnostics.\u003C/summary\u003E\r\n[Dock(\u0022Editor\u0022, \u0022Asset Doctor\u0022, \u0022local_hospital\u0022)]\r\npublic sealed class AssetDoctorWindow : Widget\r\n{\r\n    /// \u003Csummary\u003ELimits total scan findings retained in memory and exported.\u003C/summary\u003E\r\n    private const int MaxScanFindings = 50_000;\r\n    /// \u003Csummary\u003ELimits rendered occurrences under one rule group.\u003C/summary\u003E\r\n    private const int MaxRenderedFindingsPerGroup = 250;\r\n    /// \u003Csummary\u003ELimits rendered rule groups to protect editor responsiveness.\u003C/summary\u003E\r\n    private const int MaxRenderedGroups = 200;\r\n    /// \u003Csummary\u003EBounds memory used by dependency-graph edges.\u003C/summary\u003E\r\n    private const int MaxDependencyGraphEdges = 100_000;\r\n    /// \u003Csummary\u003EReferences the scrollable findings host.\u003C/summary\u003E\r\n    private ScrollArea? _scrollArea;\r\n    /// \u003Csummary\u003EReferences the current findings canvas.\u003C/summary\u003E\r\n    private Widget? _resultsContainer;\r\n    /// \u003Csummary\u003EDisplays scan and export status.\u003C/summary\u003E\r\n    private Label? _statusLabel;\r\n    /// \u003Csummary\u003EReferences the report export button.\u003C/summary\u003E\r\n    private Button? _exportButton;\r\n    /// \u003Csummary\u003EStores the cancellation source for the active scan.\u003C/summary\u003E\r\n    private CancellationTokenSource? _scanCts;\r\n    /// \u003Csummary\u003EStores findings from the most recently completed scan attempt.\u003C/summary\u003E\r\n    private List\u003CFinding\u003E _lastFindings = new();\r\n    /// \u003Csummary\u003EInvalidates stale asynchronous scan completions.\u003C/summary\u003E\r\n    private int _scanGeneration;\r\n\r\n    /// \u003Csummary\u003EInitializes the dock content.\u003C/summary\u003E\r\n    public AssetDoctorWindow(Widget parent) : base(parent, false) =\u003E BuildUI();\r\n\r\n    /// \u003Csummary\u003EBuilds fresh editor controls after creation or hotload.\u003C/summary\u003E\r\n    [EditorEvent.Hotload]\r\n    private void BuildUI()\r\n    {\r\n        CancelCurrentScan();\r\n        _scanGeneration\u002B\u002B;\r\n        if(Layout == null)\r\n        {\r\n            Layout = Layout.Column();\r\n            Layout.Margin = 4;\r\n            Layout.Spacing = 4;\r\n        }\r\n        else Layout.Clear(true);\r\n        var scanButton = new Button(\u0022Run / Restart Scan\u0022, \u0022search\u0022, this);\r\n        scanButton.Clicked \u002B= OnScanClicked;\r\n        scanButton.MinimumSize = new Vector2(220, 38);\r\n        Layout.Add(scanButton);\r\n        _exportButton = new Button(\u0022Export Reports\u0022, \u0022download\u0022, this);\r\n        _exportButton.Clicked \u002B= ExportReports;\r\n        _exportButton.MinimumSize = new Vector2(220, 38);\r\n        _exportButton.Hidden = _lastFindings.Count == 0;\r\n        Layout.Add(_exportButton);\r\n        _statusLabel = new Label(string.Empty, this) { Hidden = true };\r\n        Layout.Add(_statusLabel);\r\n        _scrollArea = new ScrollArea(this) { Hidden = true };\r\n        Layout.Add(_scrollArea);\r\n        RebuildResultsContainer();\r\n        if(_lastFindings.Count \u003E 0) RenderFindings(_lastFindings);\r\n    }\r\n\r\n    /// \u003Csummary\u003EStarts a new scan and cancels any earlier scan.\u003C/summary\u003E\r\n    private async void OnScanClicked()\r\n    {\r\n        CancelCurrentScan();\r\n        var cts = new CancellationTokenSource();\r\n        _scanCts = cts;\r\n        var generation = \u002B\u002B_scanGeneration;\r\n        _lastFindings = new();\r\n        if(_exportButton != null) _exportButton.Hidden = true;\r\n        if(_scrollArea != null) _scrollArea.Hidden = true;\r\n        if(_statusLabel != null) _statusLabel.Hidden = false;\r\n        if(_resultsContainer?.Layout != null) _resultsContainer.Layout.Clear(true);\r\n        SetStatus(\u0022Scanning current-project source assets...\u0022, \u0022#ffffff\u0022);\r\n        try\r\n        {\r\n            var snapshot = CreateSnapshot();\r\n            var findings = await Task.Run(() =\u003E ScanFiles(snapshot, cts.Token), cts.Token);\r\n            if(!IsValid || cts.IsCancellationRequested || generation != _scanGeneration) return;\r\n            RenderFindings(findings);\r\n        }\r\n        catch(OperationCanceledException)\r\n        {\r\n            if(generation == _scanGeneration) SetStatus(\u0022Scan cancelled.\u0022, \u0022#ffe19a\u0022);\r\n        }\r\n        catch(Exception exception)\r\n        {\r\n            Log.Error($\u0022Asset Doctor scan failed: {exception}\u0022);\r\n            if(generation == _scanGeneration) SetStatus($\u0022Scan failed: {exception.GetType().Name}\u0022, \u0022#ffb8c0\u0022);\r\n        }\r\n        finally\r\n        {\r\n            if(ReferenceEquals(_scanCts, cts)) _scanCts = null;\r\n            cts.Dispose();\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003ECreates a UI-thread snapshot of project files and all editor-known resolvable logical paths.\u003C/summary\u003E\r\n    private static ScanSnapshot CreateSnapshot()\r\n    {\r\n        var scope = ProjectAssetScope.TryCreate();\r\n        if(scope == null) throw new InvalidOperationException(\u0022No active project Assets directory is available.\u0022);\r\n        var allEditorAssets = AssetSystem.All.Where(x =\u003E x != null \u0026\u0026 !string.IsNullOrWhiteSpace(x.Path)).ToArray();\r\n        var resolvablePaths = new HashSet\u003Cstring\u003E(StringComparer.OrdinalIgnoreCase);\r\n        foreach(var asset in allEditorAssets)\r\n        {\r\n            resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.Path));\r\n            if(!string.IsNullOrWhiteSpace(asset.RelativePath))\r\n                resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.RelativePath));\r\n        }\r\n        var projectAssets = allEditorAssets\r\n            .Where(scope.Contains)\r\n            .Where(x =\u003E !string.IsNullOrWhiteSpace(x.AbsolutePath))\r\n            .Select(x =\u003E new SourceAsset(AssetPathRules.NormalizeSeparators(x.Path), x.AbsolutePath))\r\n            .ToArray();\r\n        return new ScanSnapshot(projectAssets, resolvablePaths);\r\n    }\r\n\r\n    /// \u003Csummary\u003EScans physical source files on a worker thread without s\u0026box interop calls.\u003C/summary\u003E\r\n    private static List\u003CFinding\u003E ScanFiles(ScanSnapshot snapshot, CancellationToken token)\r\n    {\r\n        var collector = new FindingCollector(MaxScanFindings);\r\n        var projectPaths = new Dictionary\u003Cstring, string\u003E(StringComparer.OrdinalIgnoreCase);\r\n        foreach(var asset in snapshot.Assets)\r\n        {\r\n            token.ThrowIfCancellationRequested();\r\n            if(projectPaths.TryGetValue(asset.Path, out var previous))\r\n            {\r\n                if(!collector.TryAdd(new Finding(\u0022AD110\u0022, FindingSeverity.Error, \u0022Asset paths collide when compared without letter case.\u0022, asset.Path, previous)))\r\n                    return collector.Findings;\r\n            }\r\n            else projectPaths.Add(asset.Path, asset.Path);\r\n        }\r\n\r\n        var graph = new Dictionary\u003Cstring, HashSet\u003Cstring\u003E\u003E(StringComparer.OrdinalIgnoreCase);\r\n        var graphEdgeCount = 0;\r\n        var graphLimitReported = false;\r\n        foreach(var asset in snapshot.Assets)\r\n        {\r\n            token.ThrowIfCancellationRequested();\r\n            if(!AssetPathRules.HasAnyExtension(asset.Path, AssetPathRules.TextAssetExtensions)) continue;\r\n            try\r\n            {\r\n                var info = new FileInfo(asset.AbsolutePath);\r\n                if(!info.Exists)\r\n                {\r\n                    if(!collector.TryAdd(new Finding(\u0022AD105\u0022, FindingSeverity.Error, \u0022Could not inspect text asset because its source file no longer exists.\u0022, asset.Path)))\r\n                        return collector.Findings;\r\n                    continue;\r\n                }\r\n                if(info.Length == 0) continue;\r\n                if(info.Length \u003E AssetPathRules.MaxTextFileBytes)\r\n                {\r\n                    if(!collector.TryAdd(new Finding(\u0022AD112\u0022, FindingSeverity.Warning, \u0022Text asset was skipped because it exceeds the physical file-size scan limit.\u0022, asset.Path)))\r\n                        return collector.Findings;\r\n                    continue;\r\n                }\r\n\r\n                var text = File.ReadAllText(asset.AbsolutePath);\r\n                if(text.Length \u003E AssetPathRules.MaxTextCharacters)\r\n                {\r\n                    if(!collector.TryAdd(new Finding(\u0022AD112\u0022, FindingSeverity.Warning, \u0022Text asset was skipped because it exceeds the decoded character scan limit.\u0022, asset.Path)))\r\n                        return collector.Findings;\r\n                    continue;\r\n                }\r\n\r\n                var extraction = ReferenceExtractor.Extract(text, AssetPathRules.UsesJsonEscapes(asset.Path), token);\r\n                if(extraction.IsTruncated \u0026\u0026 !collector.TryAdd(new Finding(\u0022AD113\u0022, FindingSeverity.Warning, \u0022Reference extraction stopped after reaching the per-file reference limit.\u0022, asset.Path)))\r\n                    return collector.Findings;\r\n\r\n                foreach(var reference in extraction.References)\r\n                {\r\n                    token.ThrowIfCancellationRequested();\r\n                    try\r\n                    {\r\n                        var invalid = AssetPathValidator.Validate(asset.Path, reference.Path, reference.Line);\r\n                        if(invalid != null)\r\n                        {\r\n                            if(!collector.TryAdd(invalid)) return collector.Findings;\r\n                            continue;\r\n                        }\r\n\r\n                        if(projectPaths.TryGetValue(reference.Path, out var actualProjectPath))\r\n                        {\r\n                            if(!string.Equals(reference.Path, actualProjectPath, StringComparison.Ordinal) \u0026\u0026\r\n                                !collector.TryAdd(new Finding(\u0022AD103\u0022, FindingSeverity.Error, \u0022Asset reference uses different letter case than the actual asset path.\u0022, asset.Path, reference.Path, reference.Line, actualProjectPath)))\r\n                                return collector.Findings;\r\n\r\n                            if(!graph.TryGetValue(asset.Path, out var dependencies))\r\n                            {\r\n                                dependencies = new HashSet\u003Cstring\u003E(StringComparer.OrdinalIgnoreCase);\r\n                                graph.Add(asset.Path, dependencies);\r\n                            }\r\n\r\n                            if(!dependencies.Contains(actualProjectPath))\r\n                            {\r\n                                if(graphEdgeCount \u003C MaxDependencyGraphEdges)\r\n                                {\r\n                                    dependencies.Add(actualProjectPath);\r\n                                    graphEdgeCount\u002B\u002B;\r\n                                }\r\n                                else if(!graphLimitReported)\r\n                                {\r\n                                    if(!collector.TryAdd(new Finding(\r\n                                        \u0022AD115\u0022,\r\n                                        FindingSeverity.Warning,\r\n                                        \u0022Dependency graph limit was reached. Circular-reference results may be incomplete.\u0022,\r\n                                        asset.Path)))\r\n                                    {\r\n                                        return collector.Findings;\r\n                                    }\r\n\r\n                                    graphLimitReported = true;\r\n                                }\r\n                            }\r\n                        }\r\n                        else if(!snapshot.ResolvablePaths.Contains(reference.Path))\r\n                        {\r\n                            if(!collector.TryAdd(new Finding(\u0022AD100\u0022, FindingSeverity.Error, \u0022Missing asset reference.\u0022, asset.Path, reference.Path, reference.Line)))\r\n                                return collector.Findings;\r\n                        }\r\n                    }\r\n                    catch(Exception exception)\r\n                    {\r\n                        if(!collector.TryAdd(new Finding(\u0022AD111\u0022, FindingSeverity.Error, $\u0022Could not validate asset reference ({exception.GetType().Name}).\u0022, asset.Path, reference.Path, reference.Line)))\r\n                            return collector.Findings;\r\n                    }\r\n                }\r\n            }\r\n            catch(Exception exception)\r\n            {\r\n                if(!collector.TryAdd(new Finding(\u0022AD105\u0022, FindingSeverity.Error, $\u0022Could not read text asset ({exception.GetType().Name}).\u0022, asset.Path)))\r\n                    return collector.Findings;\r\n            }\r\n        }\r\n\r\n        foreach(var cycleFinding in AssetReferenceCycleDetector.Find(graph, token))\r\n        {\r\n            if(!collector.TryAdd(cycleFinding)) break;\r\n        }\r\n        return collector.Findings;\r\n    }\r\n\r\n    /// \u003Csummary\u003ERenders bounded groups from completed findings.\u003C/summary\u003E\r\n    private void RenderFindings(List\u003CFinding\u003E findings)\r\n    {\r\n        if(_resultsContainer?.Layout == null) return;\r\n        _lastFindings = findings;\r\n        _resultsContainer.Layout.Clear(true);\r\n        if(_scrollArea != null)\r\n        {\r\n            _scrollArea.Hidden = false;\r\n            _scrollArea.MinimumSize = new Vector2(0, 280);\r\n        }\r\n        if(_exportButton != null) _exportButton.Hidden = findings.Count == 0;\r\n        var errors = findings.Count(x =\u003E x.Severity == FindingSeverity.Error);\r\n        var warnings = findings.Count(x =\u003E x.Severity == FindingSeverity.Warning);\r\n        SetStatus($\u0022Found {findings.Count} issues \u00B7 {errors} errors \u00B7 {warnings} warnings \u00B7 heuristic quoted-path scan\u0022, errors \u003E 0 ? \u0022#ffb8c0\u0022 : \u0022#9ee6a5\u0022);\r\n        var groups = findings.GroupBy(x =\u003E new { x.RuleId, x.Severity, x.Message }).OrderByDescending(x =\u003E Rank(x.Key.Severity)).ThenBy(x =\u003E x.Key.RuleId).Take(MaxRenderedGroups).ToArray();\r\n        foreach(var group in groups) AddGroup(group.Key.RuleId, group.Key.Severity, group.Key.Message, group);\r\n        if(findings.Count \u003E 0 \u0026\u0026 groups.Length == MaxRenderedGroups) _resultsContainer.Layout.Add(new Label(\u0022More rule groups were omitted to protect Editor performance. Export reports for the full list.\u0022, _resultsContainer));\r\n        _resultsContainer.Layout.AddStretchCell();\r\n    }\r\n\r\n    /// \u003Csummary\u003EAdds one collapsible rule group to the results canvas.\u003C/summary\u003E\r\n    private void AddGroup(string ruleId, FindingSeverity severity, string message, IEnumerable\u003CFinding\u003E findings)\r\n    {\r\n        if(_resultsContainer?.Layout == null) return;\r\n        var all = findings.OrderBy(x =\u003E x.SourcePath, StringComparer.OrdinalIgnoreCase).ThenBy(x =\u003E x.ReferencedPath, StringComparer.OrdinalIgnoreCase).ToArray();\r\n        var rendered = all.Take(MaxRenderedFindingsPerGroup).ToArray();\r\n        var isError = severity == FindingSeverity.Error;\r\n        var background = isError ? \u0022#4a1f25\u0022 : \u0022#4a3a12\u0022;\r\n        var border = isError ? \u0022#d94b58\u0022 : \u0022#e0ae32\u0022;\r\n        var text = isError ? \u0022#ffb8c0\u0022 : \u0022#ffe19a\u0022;\r\n        var container = new Widget(_resultsContainer) { Layout = Layout.Column() };\r\n        container.Layout.Margin = 0;\r\n        container.Layout.Spacing = 1;\r\n        _resultsContainer.Layout.Add(container);\r\n        var closed = $\u0022\u25B6 {ruleId} \u00B7 {message} \u00B7 {all.Length} issue(s)\u0022;\r\n        var open = $\u0022\u25BC {ruleId} \u00B7 {message} \u00B7 {all.Length} issue(s)\u0022;\r\n        var details = new Widget(container) { Layout = Layout.Column(), Hidden = true };\r\n        details.Layout.Margin = 0;\r\n        details.Layout.Spacing = 1;\r\n        var header = new FindingRow(container, closed, background, border, text);\r\n        header.Clicked \u002B= () =\u003E\r\n        {\r\n            if(!details.IsValid || !header.IsValid) return;\r\n            details.Hidden = !details.Hidden;\r\n            header.Text = details.Hidden ? closed : open;\r\n        };\r\n        container.Layout.Add(header);\r\n        foreach(var finding in rendered)\r\n        {\r\n            var source = finding.SourcePath;\r\n            var holder = new Widget(details) { Layout = Layout.Row() };\r\n            holder.Layout.Margin = 0;\r\n            holder.Layout.Spacing = 0;\r\n            holder.Layout.Add(new Widget(holder) { FixedWidth = 18 });\r\n            var detail = finding.Details == null ? string.Empty : $\u0022 \u00B7 {finding.Details}\u0022;\r\n            var line = finding.Line.HasValue ? $\u0022:{finding.Line.Value}\u0022 : string.Empty;\r\n            var row = new FindingRow(holder, $\u0022{finding.RuleId} \u00B7 {finding.Message} \u00B7 {finding.SourcePath}{line} \u2192 {finding.ReferencedPath}{detail}\u0022, background, border, text);\r\n            row.Clicked \u002B= () =\u003E AssetBrowserNavigator.FocusAsset(source);\r\n            holder.Layout.Add(row, 1);\r\n            details.Layout.Add(holder);\r\n        }\r\n        if(all.Length \u003E rendered.Length) details.Layout.Add(new Label($\u0022\u2026 {all.Length - rendered.Length} more issue(s); export reports for the full list.\u0022, details));\r\n        container.Layout.Add(details);\r\n    }\r\n\r\n    /// \u003Csummary\u003EExports reports through a user-selected directory.\u003C/summary\u003E\r\n    private void ExportReports()\r\n    {\r\n        if(_lastFindings.Count == 0) return;\r\n        var dialog = new FileDialog(this) { Title = \u0022Select Report Folder\u0022 };\r\n        dialog.SetFindDirectory();\r\n        if(!dialog.Execute() || string.IsNullOrWhiteSpace(dialog.Directory)) return;\r\n        try\r\n        {\r\n            var name = Path.GetFileName(Project.Current?.GetAssetsPath()?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) ?? \u0022project\u0022;\r\n            AssetDoctorReportExporter.Export(dialog.Directory, name, _lastFindings);\r\n            SetStatus($\u0022Reports exported to {dialog.Directory}\u0022, \u0022#9ee6a5\u0022);\r\n        }\r\n        catch(Exception exception)\r\n        {\r\n            Log.Error($\u0022Asset Doctor export failed: {exception}\u0022);\r\n            SetStatus($\u0022Export failed: {exception.GetType().Name}\u0022, \u0022#ffb8c0\u0022);\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003ECreates the scroll canvas used for results.\u003C/summary\u003E\r\n    private void RebuildResultsContainer()\r\n    {\r\n        if(_scrollArea == null) return;\r\n        _resultsContainer = new Widget(null) { Layout = Layout.Column() };\r\n        _resultsContainer.Layout.Spacing = 2;\r\n        _scrollArea.Canvas = _resultsContainer;\r\n    }\r\n\r\n    /// \u003Csummary\u003ECancels and detaches the active scan; its owner disposes the source after completion.\u003C/summary\u003E\r\n    private void CancelCurrentScan()\r\n    {\r\n        var current = Interlocked.Exchange(ref _scanCts, null);\r\n        current?.Cancel();\r\n    }\r\n\r\n    /// \u003Csummary\u003EUpdates status text and its severity color.\u003C/summary\u003E\r\n    private void SetStatus(string text, string color)\r\n    {\r\n        if(_statusLabel == null) return;\r\n        _statusLabel.Hidden = false;\r\n        _statusLabel.Text = text;\r\n        _statusLabel.SetStyles($\u0022padding: 4px 2px; color: {color};\u0022);\r\n    }\r\n\r\n    /// \u003Csummary\u003EReturns sort precedence for severities.\u003C/summary\u003E\r\n    private static int Rank(FindingSeverity severity) =\u003E severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;\r\n    /// \u003Csummary\u003EBounds retained findings and adds one explicit truncation diagnostic when the scan limit is reached.\u003C/summary\u003E\r\n    private sealed class FindingCollector\r\n    {\r\n        /// \u003Csummary\u003EStores retained findings.\u003C/summary\u003E\r\n        private readonly List\u003CFinding\u003E _findings = new();\r\n        /// \u003Csummary\u003EStores the maximum retained finding count.\u003C/summary\u003E\r\n        private readonly int _limit;\r\n        /// \u003Csummary\u003ETracks whether the terminal limit finding was added.\u003C/summary\u003E\r\n        private bool _truncated;\r\n\r\n        /// \u003Csummary\u003EInitializes a bounded finding collector.\u003C/summary\u003E\r\n        public FindingCollector(int limit) =\u003E _limit = limit;\r\n        /// \u003Csummary\u003EGets retained findings.\u003C/summary\u003E\r\n        public List\u003CFinding\u003E Findings =\u003E _findings;\r\n        /// \u003Csummary\u003EAdds a finding or a final truncation diagnostic, returning false when scanning must stop.\u003C/summary\u003E\r\n        public bool TryAdd(Finding finding)\r\n        {\r\n            if(_truncated) return false;\r\n            if(_findings.Count \u003C _limit - 1)\r\n            {\r\n                _findings.Add(finding);\r\n                return true;\r\n            }\r\n\r\n            _findings.Add(new Finding(\u0022AD114\u0022, FindingSeverity.Warning, \u0022Scan stopped after reaching the global finding limit.\u0022, finding.SourcePath, finding.ReferencedPath, finding.Line));\r\n            _truncated = true;\r\n            return false;\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003ERepresents a physical source file captured on the UI thread.\u003C/summary\u003E\r\n    private sealed record SourceAsset(string Path, string AbsolutePath);\r\n    /// \u003Csummary\u003ERepresents a scan-input snapshot that worker code treats as read-only.\u003C/summary\u003E\r\n    private sealed record ScanSnapshot(SourceAsset[] Assets, HashSet\u003Cstring\u003E ResolvablePaths);\r\n}\r\n"},{"Ident":"mikekotys.assetdoctor","Path":"Editor/FindingRow.cs","FileName":"FindingRow.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":337738,"Code":"#nullable enable\r\nusing System;\r\n\r\nnamespace AssetDoctor;\r\n\r\n/// \u003Csummary\u003EProvides a styled clickable row without native button rendering.\u003C/summary\u003E\r\npublic sealed class FindingRow : Widget\r\n{\r\n    /// \u003Csummary\u003EDisplays the row text.\u003C/summary\u003E\r\n    private readonly Label _label;\r\n    /// \u003Csummary\u003ERaised when the row receives a mouse click.\u003C/summary\u003E\r\n    public event Action? Clicked;\r\n    /// \u003Csummary\u003EGets or sets the visible row text.\u003C/summary\u003E\r\n    public string Text { get =\u003E _label.Text; set =\u003E _label.Text = value; }\r\n    /// \u003Csummary\u003ECreates a styled row using caller-supplied colors.\u003C/summary\u003E\r\n    public FindingRow(Widget parent, string text, string background, string border, string color) : base(parent)\r\n    {\r\n        MinimumSize = new Vector2(0, 24); Layout = Layout.Row(); Layout.Margin = 0; Layout.Spacing = 0;\r\n        SetStyles($\u0022background-color: {background}; border: 1px solid {border};\u0022);\r\n        _label = new Label(text, this) { TransparentForMouseEvents = true };\r\n        _label.SetStyles($\u0022color: {color}; padding: 0px 6px; background-color: transparent;\u0022); Layout.Add(_label);\r\n        MouseClick \u002B= OnMouseClick;\r\n    }\r\n    /// \u003Csummary\u003ERaises the row click event.\u003C/summary\u003E\r\n    private void OnMouseClick() =\u003E Clicked?.Invoke();\r\n}\r\n"}]}