🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (12 total matches found)
mikekotys.assetdoctor / Editor/AssetBrowserNavigator.cs
Editor library
#nullable enable
using System;

namespace AssetDoctor;

/// <summary>Focuses an editor-known asset in its Asset Browser view.</summary>
public static class AssetBrowserNavigator
{
    /// <summary>Attempts to focus an asset by logical path and falls back to highlighting the path.</summary>
    public static void FocusAsset(string path)
    {
        if(string.IsNullOrWhiteSpace(path)) return;
        var asset = AssetSystem.All.FirstOrDefault(item =>
            item != null && string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase));
        if(asset == null)
        {
            EditorEvent.Run("assetsystem.highlight", path);
            Log.Warning($"Asset Doctor could not select '{path}'; sent an Asset Browser highlight instead.");
            return;
        }

        var browser = AssetBrowser.Get();
        var assetBrowser = browser?.GetBrowser(asset);
        if(assetBrowser == null)
        {
            Log.Warning($"Asset Doctor could not locate an Asset Browser view for '{path}'.");
            return;
        }

        assetBrowser.FocusOnAsset(asset, true);
    }
}
mikekotys.assetdoctor / Editor/AssetDoctorReportExporter.cs
Editor library
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;

namespace AssetDoctor;

/// <summary>Writes timestamped Markdown, text, and JSON reports for completed heuristic scans.</summary>
public static class AssetDoctorReportExporter
{
    /// <summary>Exports all report formats with one timestamp and attempts to remove partial final files on failure.</summary>
    public static IReadOnlyList<string> Export(string directory, string projectName, IReadOnlyCollection<Finding> findings)
    {
        if(string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("A report directory is required.", nameof(directory));
        if(findings == null) throw new ArgumentNullException(nameof(findings));
        Directory.CreateDirectory(directory);
        var generatedAt = DateTimeOffset.UtcNow;
        var stamp = generatedAt.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture);
        var safeProject = SanitizeFileName(string.IsNullOrWhiteSpace(projectName) ? "project" : projectName);
        var unique = Guid.NewGuid().ToString("N")[..8];
        var prefix = $"asset_doctor_{safeProject}_{stamp}_{unique}";
        var ordered = findings.OrderByDescending(x => Rank(x.Severity)).ThenBy(x => x.RuleId, StringComparer.Ordinal).ThenBy(x => x.SourcePath, StringComparer.Ordinal).ToArray();
        var finals = new[] { Path.Combine(directory, prefix + ".md"), Path.Combine(directory, prefix + ".txt"), Path.Combine(directory, prefix + ".json") };
        var temps = finals.Select(path => path + ".tmp-" + Guid.NewGuid().ToString("N")).ToArray();
        var moved = new List<string>();
        try
        {
            WriteMarkdown(temps[0], ordered, generatedAt);
            WriteText(temps[1], ordered, generatedAt);
            WriteJson(temps[2], ordered, generatedAt);
            for(var index = 0; index < finals.Length; index++)
            {
                File.Move(temps[index], finals[index], false);
                moved.Add(finals[index]);
            }
            return finals;
        }
        catch
        {
            foreach(var path in moved) TryDelete(path);
            throw;
        }
        finally
        {
            foreach(var path in temps) TryDelete(path);
        }
    }

    /// <summary>Writes a Markdown report without building an additional full report string in memory.</summary>
    private static void WriteMarkdown(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
    {
        using var writer = CreateWriter(path);
        writer.WriteLine("# Asset Doctor Report");
        writer.WriteLine();
        writer.WriteLine($"Generated: {generatedAt:O}");
        writer.WriteLine("Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.");
        writer.WriteLine($"Issues: {findings.Count}");
        writer.WriteLine();
        foreach(var finding in findings)
        {
            writer.WriteLine($"## {EscapeMarkdown(finding.RuleId)} · {finding.Severity}");
            writer.WriteLine();
            writer.WriteLine(EscapeMarkdown(finding.Message));
            writer.WriteLine($"- Source: {Code(finding.SourcePath)}");
            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($"- Reference: {Code(finding.ReferencedPath)}");
            if(finding.Line.HasValue) writer.WriteLine($"- Line: {finding.Line.Value}");
            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($"- Details: {Code(finding.Details)}");
            writer.WriteLine();
        }
    }

    /// <summary>Writes a plain-text report without building an additional full report string in memory.</summary>
    private static void WriteText(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
    {
        using var writer = CreateWriter(path);
        writer.WriteLine("ASSET DOCTOR REPORT");
        writer.WriteLine($"Generated: {generatedAt:O}");
        writer.WriteLine("Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.");
        writer.WriteLine($"Issues: {findings.Count}");
        writer.WriteLine();
        foreach(var finding in findings)
        {
            writer.WriteLine($"{finding.RuleId} · {finding.Severity} · {Plain(finding.Message)}");
            writer.WriteLine($"Source: {Plain(finding.SourcePath)}");
            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($"Reference: {Plain(finding.ReferencedPath)}");
            if(finding.Line.HasValue) writer.WriteLine($"Line: {finding.Line.Value}");
            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($"Details: {Plain(finding.Details)}");
            writer.WriteLine();
        }
    }

    /// <summary>Writes a machine-readable JSON report without requiring external serializer packages.</summary>
    private static void WriteJson(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
    {
        using var writer = CreateWriter(path);
        writer.WriteLine("{");
        writer.WriteLine($"  \"generatedAt\": {Json(generatedAt.ToString("O", CultureInfo.InvariantCulture))},");
        writer.WriteLine("  \"detectionMode\": \"heuristic quoted-path scan; not a complete dependency graph\",");
        writer.WriteLine("  \"findings\": [");
        for(var index = 0; index < findings.Count; index++)
        {
            var finding = findings[index];
            writer.Write("    { \"ruleId\": "); writer.Write(Json(finding.RuleId));
            writer.Write(", \"severity\": "); writer.Write(Json(finding.Severity.ToString()));
            writer.Write(", \"message\": "); writer.Write(Json(finding.Message));
            writer.Write(", \"sourcePath\": "); writer.Write(Json(finding.SourcePath));
            writer.Write(", \"referencedPath\": "); writer.Write(Json(finding.ReferencedPath));
            writer.Write(", \"line\": "); writer.Write(finding.Line?.ToString(CultureInfo.InvariantCulture) ?? "null");
            writer.Write(", \"details\": "); writer.Write(Json(finding.Details));
            writer.Write(" }");
            if(index + 1 < findings.Count) writer.Write(',');
            writer.WriteLine();
        }
        writer.WriteLine("  ]");
        writer.WriteLine("}");
    }

    /// <summary>Creates a UTF-8 writer for one temporary report.</summary>
    private static StreamWriter CreateWriter(string path) => new(path, false, new UTF8Encoding(false));

    /// <summary>Escapes a JSON string including control characters.</summary>
    private static string Json(string? value)
    {
        if(value == null) return "null";
        var builder = new StringBuilder(value.Length + 2).Append('"');
        foreach(var character in value)
        {
            switch(character)
            {
                case '\\': builder.Append("\\\\"); break;
                case '"': builder.Append("\\\""); break;
                case '\b': builder.Append("\\b"); break;
                case '\f': builder.Append("\\f"); break;
                case '\n': builder.Append("\\n"); break;
                case '\r': builder.Append("\\r"); break;
                case '\t': builder.Append("\\t"); break;
                default:
                    if(character < ' ') builder.Append($"\\u{(int)character:X4}");
                    else builder.Append(character);
                    break;
            }
        }
        return builder.Append('"').ToString();
    }

    /// <summary>Escapes Markdown characters that could alter report structure.</summary>
    private static string EscapeMarkdown(string? value) => Plain(value).Replace("\\", "\\\\").Replace("`", "\\`").Replace("*", "\\*").Replace("_", "\\_").Replace("[", "\\[").Replace("]", "\\]").Replace("#", "\\#").Replace("|", "\\|").Replace("!", "\\!").Replace("~", "\\~").Replace("<", "&lt;").Replace(">", "&gt;");

    /// <summary>Uses a variable-length inline-code delimiter so embedded backticks remain literal.</summary>
    private static string Code(string? value)
    {
        var text = Plain(value);
        var fence = "`";
        while(text.Contains(fence, StringComparison.Ordinal)) fence += "`";
        return fence + text + fence;
    }

    /// <summary>Renders selected control and directional characters visibly to reduce report spoofing.</summary>
    private static string Plain(string? value)
    {
        if(string.IsNullOrEmpty(value)) return string.Empty;
        var builder = new StringBuilder(value.Length);
        foreach(var character in value) builder.Append(char.IsControl(character) || character == '\u202E' ? $"\\u{(int)character:X4}" : character);
        return builder.ToString();
    }

    /// <summary>Replaces filename characters that are invalid on the current platform.</summary>
    private static string SanitizeFileName(string value) => string.Concat(value.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character));

    /// <summary>Deletes temporary or rolled-back files without masking the primary export error.</summary>
    private static void TryDelete(string path)
    {
        try { if(File.Exists(path)) File.Delete(path); }
        catch { }
    }

    /// <summary>Returns a deterministic severity sort rank.</summary>
    private static int Rank(FindingSeverity severity) => severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;
}
mikekotys.assetdoctor / Editor/AssetContextMenuActions.cs
Editor library
#nullable enable
namespace AssetDoctor;

/// <summary>Adds Asset Doctor direct-link actions to the Asset Browser context menu.</summary>
public static class AssetContextMenuActions
{
    /// <summary>Stores the nested menu path for reverse dependency lookup.</summary>
    private static readonly string[] FindDependantsMenuPath = { "Asset Doctor", "Find Assets Using This" };
    /// <summary>Stores the nested menu path for forward dependency lookup.</summary>
    private static readonly string[] FindReferencesMenuPath = { "Asset Doctor", "Find Assets Used By This" };

    /// <summary>Registers direct-link actions when exactly one valid Asset Browser entry is selected.</summary>
    [Event("asset.contextmenu")]
    private static void OnAssetContextMenu(AssetContextMenu context)
    {
        if(context.SelectedList == null || context.SelectedList.Count != 1) return;
        var asset = context.SelectedList[0].Asset;
        if(asset == null || string.IsNullOrWhiteSpace(asset.Path)) return;
        var added = false;

        context.Menu.AboutToShow += () =>
        {
            if(added) return;
            added = true;

            context.Menu.AddSeparator();
            context.Menu.AddOption(
                FindDependantsMenuPath,
                "manage_search",
                () => new AssetLinksWindow(asset, true),
                "Show assets that directly use this asset");

            context.Menu.AddOption(
                FindReferencesMenuPath,
                "account_tree",
                () => new AssetLinksWindow(asset, false),
                "Show assets directly used by this asset");
        };
    }
}
mikekotys.assetdoctor / Editor/AssetDoctorCore.cs
Editor library
#nullable enable
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;

namespace AssetDoctor;

/// <summary>Describes the severity assigned to a validation result.</summary>
public enum FindingSeverity { Info, Warning, Error }

/// <summary>Represents one diagnostic produced by a scan.</summary>
public sealed record Finding(string RuleId, FindingSeverity Severity, string Message, string SourcePath, string? ReferencedPath = null, int? Line = null, string? Details = null);

/// <summary>Stores shared asset-path constants and normalization helpers.</summary>
public static class AssetPathRules
{
    /// <summary>Limits an individual quoted candidate to prevent pathological scans.</summary>
    public const int MaxReferenceLength = 4096;
    /// <summary>Limits extracted references from one source file to protect scan memory.</summary>
    public const int MaxReferencesPerSourceFile = 10_000;
    /// <summary>Limits physical source-file bytes before allocating text in memory.</summary>
    public const long MaxTextFileBytes = 4_000_000;
    /// <summary>Limits decoded source characters after a successful read.</summary>
    public const int MaxTextCharacters = 4_000_000;
    /// <summary>Lists source formats whose quoted values are currently scanned heuristically.</summary>
    public static readonly string[] TextAssetExtensions = { ".scene", ".prefab", ".vmdl", ".vmat", ".vtex", ".sound", ".surface", ".clothing", ".decal", ".vmap", ".vfx", ".vanmgrph", ".vpost", ".shader", ".shdrgrph", ".vpcf", ".json" };
    /// <summary>Lists referenced asset extensions recognized inside quoted source values.</summary>
    public static readonly string[] ReferenceExtensions = { ".scene", ".prefab", ".vmdl", ".vmat", ".vtex", ".sound", ".surface", ".clothing", ".decal", ".vmap", ".vfx", ".vanmgrph", ".vpost", ".shader", ".shdrgrph", ".vpcf", ".png", ".jpg", ".jpeg", ".tga", ".fbx", ".json" };
    /// <summary>Returns whether a path ends in one of the supplied extensions.</summary>
    public static bool HasAnyExtension(string path, IReadOnlyList<string> extensions)
    {
        if(string.IsNullOrEmpty(path)) return false;
        for(var index = 0; index < extensions.Count; index++) if(path.EndsWith(extensions[index], StringComparison.OrdinalIgnoreCase)) return true;
        return false;
    }
    /// <summary>Converts one or more Windows separators without removing unsafe whitespace.</summary>
    public static string NormalizeSeparators(string path) => (path ?? string.Empty).Replace('\\', '/');
    /// <summary>Returns whether references in this source format should use JSON-style escape decoding.</summary>
    public static bool UsesJsonEscapes(string path) => path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".scene", StringComparison.OrdinalIgnoreCase);
}

/// <summary>Represents one extracted asset path and its one-based source line.</summary>
public sealed record AssetReference(string Path, int Line);

/// <summary>Contains bounded reference extraction output and indicates whether the per-file limit was reached.</summary>
public sealed record ReferenceExtractionResult(IReadOnlyList<AssetReference> References, bool IsTruncated);

/// <summary>Extracts quoted asset paths with bounded linear-time scanning.</summary>
public static class ReferenceExtractor
{
    /// <summary>Extracts recognized references from text; output is heuristic because only quoted values are examined.</summary>
    public static ReferenceExtractionResult Extract(string? text, bool decodeJsonEscapes, CancellationToken token = default)
    {
        var results = new List<AssetReference>();
        if(string.IsNullOrEmpty(text)) return new ReferenceExtractionResult(results, false);
        var line = 1;
        for(var index = 0; index < text.Length; index++)
        {
            if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();
            if(text[index] == '\n') { line++; continue; }
            var quote = text[index];
            if(quote != '\'' && quote != '"') continue;
            var startLine = line;
            var start = ++index;
            var escaped = false;
            var tooLong = false;
            var closed = false;
            while(index < text.Length)
            {
                if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();
                var character = text[index];
                if(character == '\n') line++;
                if(character == quote && !escaped) { closed = true; break; }
                if(index - start >= AssetPathRules.MaxReferenceLength) tooLong = true;
                escaped = character == '\\' ? !escaped : false;
                index++;
            }
            if(!closed || tooLong) continue;
            var raw = text.Substring(start, index - start);
            if(!TryDecodeEscapes(raw, decodeJsonEscapes, out var decoded)) continue;
            var normalized = AssetPathRules.NormalizeSeparators(decoded);
            if(normalized.Length == 0 || !AssetPathRules.HasAnyExtension(normalized, AssetPathRules.ReferenceExtensions)) continue;
            results.Add(new AssetReference(normalized, startLine));
            if(results.Count >= AssetPathRules.MaxReferencesPerSourceFile)
                return new ReferenceExtractionResult(results, true);
        }
        return new ReferenceExtractionResult(results, false);
    }

    /// <summary>Decodes JSON escapes only for JSON-like source formats and preserves ordinary backslash paths otherwise.</summary>
    private static bool TryDecodeEscapes(string raw, bool decodeJsonEscapes, out string value)
    {
        if(!decodeJsonEscapes) { value = raw; return true; }
        var builder = new StringBuilder(raw.Length);
        for(var index = 0; index < raw.Length; index++)
        {
            var character = raw[index];
            if(character != '\\') { builder.Append(character); continue; }
            if(++index >= raw.Length) { value = string.Empty; return false; }
            switch(raw[index])
            {
                case '"': builder.Append('"'); break;
                case '\\': builder.Append('\\'); break;
                case '/': builder.Append('/'); break;
                case 'b': builder.Append('\b'); break;
                case 'f': builder.Append('\f'); break;
                case 'n': builder.Append('\n'); break;
                case 'r': builder.Append('\r'); break;
                case 't': builder.Append('\t'); break;
                case 'u' when index + 4 < raw.Length:
                    var hex = raw.Substring(index + 1, 4);
                    if(!ushort.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var code)) { value = string.Empty; return false; }
                    builder.Append((char)code); index += 4; break;
                default: value = string.Empty; return false;
            }
        }
        value = builder.ToString();
        return true;
    }
}
mikekotys.assetdoctor / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Asset Doctor" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "assetdoctor" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "mikekotys" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "mikekotys.assetdoctor" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-31T09:27:02.2172776Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.113.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.113.0")]
mikekotys.assetdoctor / Editor/Assembly.cs
Editor library
// Shared editor namespaces for this single s&box editor-package assembly.
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
mikekotys.assetdoctor / Editor/AssetPathValidator.cs
Editor library
#nullable enable
using System;
using System.Text;

namespace AssetDoctor;

/// <summary>Validates that extracted references are safe portable asset paths.</summary>
public static class AssetPathValidator
{
    /// <summary>Returns the highest-priority finding for a reference, or null when no issue is detected.</summary>
    public static Finding? Validate(string sourcePath, string? referencedPath, int? line = null)
    {
        try
        {
            if(string.IsNullOrWhiteSpace(referencedPath)) return null;
            if(!string.Equals(referencedPath, referencedPath.Trim(), StringComparison.Ordinal)) return New("AD109", FindingSeverity.Warning, "Asset path has leading or trailing whitespace.", sourcePath, referencedPath, line);
            var path = AssetPathRules.NormalizeSeparators(referencedPath);
            foreach(var character in path) if(char.IsControl(character) || character == '\u202E') return New("AD101", FindingSeverity.Error, "Asset path contains unsafe control or directional characters.", sourcePath, referencedPath, line);
            if(path.StartsWith("mount://", StringComparison.OrdinalIgnoreCase)) return New("AD102", FindingSeverity.Error, "Mounted assets cannot be included in a published package.", sourcePath, referencedPath, line);
            if(path.Contains("://", StringComparison.Ordinal)) return New("AD106", FindingSeverity.Error, "External URI used where a project-relative asset path is expected.", sourcePath, referencedPath, line);
            var drivePath = path.Length >= 3 && ((path[0] is >= 'A' and <= 'Z') || (path[0] is >= 'a' and <= 'z')) && path[1] == ':' && path[2] == '/';
            if(path.StartsWith("/", StringComparison.Ordinal) || drivePath || path.IndexOf(':') >= 0) return New("AD107", FindingSeverity.Error, "Absolute or drive-relative asset paths are not portable.", sourcePath, referencedPath, line);
            var decoded = TryPercentDecode(path);
            foreach(var segment in decoded.Split('/')) if(segment == "..") return New("AD108", FindingSeverity.Error, "Parent-directory traversal is not allowed in asset paths.", sourcePath, referencedPath, line);
            if(path.StartsWith("./", StringComparison.Ordinal) || path.Contains("//", StringComparison.Ordinal) || path.Contains("/./", StringComparison.Ordinal) || !path.IsNormalized(NormalizationForm.FormC)) return New("AD109", FindingSeverity.Warning, "Asset path is not canonical.", sourcePath, referencedPath, line);
            return null;
        }
        catch(Exception exception) when(exception is ArgumentException || exception is UriFormatException)
        {
            return New("AD101", FindingSeverity.Error, "Asset path contains invalid Unicode or encoding.", sourcePath, referencedPath ?? string.Empty, line);
        }
    }

    /// <summary>Attempts to decode percent escapes for traversal detection without changing the reported original path.</summary>
    private static string TryPercentDecode(string path)
    {
        try { return Uri.UnescapeDataString(path); }
        catch(UriFormatException) { return path; }
    }

    /// <summary>Creates a finding with consistent source-location metadata.</summary>
    private static Finding New(string ruleId, FindingSeverity severity, string message, string source, string reference, int? line) => new(ruleId, severity, message, source, reference, line);
}
mikekotys.assetdoctor / Editor/AssetLinksWindow.cs
Editor library
#nullable enable
using System;
using System.Linq;

namespace AssetDoctor;

/// <summary>Shows direct native s&box references or dependants for one selected asset.</summary>
public sealed class AssetLinksWindow : Widget
{
    /// <summary>Limits synchronous row creation while retaining the true lookup count.</summary>
    private const int MaxRenderedAssets = 500;

    /// <summary>Creates and displays a compact direct-link inspector.</summary>
    public AssetLinksWindow(Asset asset, bool showDependants) : base(null)
    {
        if(asset == null) throw new ArgumentNullException(nameof(asset));
        WindowTitle = showDependants ? "Find Assets Using This" : "Find Assets Used By This";
        MinimumSize = new Vector2(420, 260);
        Size = new Vector2(520, 340);
        Layout = Layout.Column();
        Layout.Margin = 6;
        Layout.Spacing = 4;
        Layout.Add(new Label(showDependants ? $"Assets directly using: {asset.Path}" : $"Assets directly used by: {asset.Path}", this));
        Asset[] allLinks;
        try
        {
            allLinks = (showDependants ? asset.GetDependants(false) : asset.GetReferences(false))?
                .Where(x => x != null && !string.IsNullOrWhiteSpace(x.Path))
                .GroupBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
                .Select(x => x.First())
                .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
                .ToArray() ?? Array.Empty<Asset>();
        }
        catch(Exception exception)
        {
            Log.Error($"Asset Doctor link lookup failed: {exception}");
            Layout.Add(new Label($"Lookup failed: {exception.GetType().Name}", this));
            Show();
            return;
        }
        var renderedLinks = allLinks.Take(MaxRenderedAssets).ToArray();
        Layout.Add(new Label($"Found {allLinks.Length} direct asset(s)", this));
        var scroll = new ScrollArea(this);
        Layout.Add(scroll);
        var content = new Widget(null) { Layout = Layout.Column() };
        content.Layout.Spacing = 2;
        scroll.Canvas = content;
        foreach(var link in renderedLinks)
        {
            var target = link;
            var row = new FindingRow(content, target.Path, "#1d2c3a", "#3a6d8f", "#c7e8ff");
            row.Clicked += () => AssetBrowserNavigator.FocusAsset(target.Path);
            content.Layout.Add(row);
        }
        if(allLinks.Length == 0) content.Layout.Add(new Label("No direct asset links found.", content));
        if(allLinks.Length > renderedLinks.Length) content.Layout.Add(new Label($"… {allLinks.Length - renderedLinks.Length} more asset(s) were not rendered to protect Editor responsiveness.", content));
        content.Layout.AddStretchCell();
        Show();
    }
}
mikekotys.assetdoctor / Editor/AssetReferenceCycleDetector.cs
Editor library
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;

namespace AssetDoctor;

/// <summary>Finds direct-reference cycles using an iterative depth-first traversal.</summary>
public static class AssetReferenceCycleDetector
{
    /// <summary>Represents the traversal state for one active graph node.</summary>
    private sealed class Frame
    {
        /// <summary>Initializes a frame with the supplied dependency list.</summary>
        public Frame(string node, string[] dependencies) { Node = node; Dependencies = dependencies; }
        /// <summary>Gets the active node path.</summary>
        public string Node { get; }
        /// <summary>Gets dependencies to visit.</summary>
        public string[] Dependencies { get; }
        /// <summary>Gets or sets the next dependency index.</summary>
        public int NextIndex { get; set; }
    }

    /// <summary>Finds cycles in a graph and returns a single finding per back-edge path.</summary>
    public static List<Finding> Find(IReadOnlyDictionary<string, HashSet<string>> graph, CancellationToken token = default)
    {
        if(graph == null) throw new ArgumentNullException(nameof(graph));
        var findings = new List<Finding>();
        var states = new Dictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
        var active = new List<string>();
        var positions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        var frames = new List<Frame>();
        var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach(var start in graph.Keys)
        {
            token.ThrowIfCancellationRequested();
            if(states.ContainsKey(start)) continue;
            Push(start);
            while(frames.Count > 0)
            {
                token.ThrowIfCancellationRequested();
                var frame = frames[^1];
                if(frame.NextIndex >= frame.Dependencies.Length)
                {
                    frames.RemoveAt(frames.Count - 1); positions.Remove(frame.Node); active.RemoveAt(active.Count - 1); states[frame.Node] = 2; continue;
                }
                var dependency = frame.Dependencies[frame.NextIndex++];
                if(!states.TryGetValue(dependency, out var state)) { Push(dependency); continue; }
                if(state != 1 || !positions.TryGetValue(dependency, out var startIndex)) continue;
                var cycle = active.GetRange(startIndex, active.Count - startIndex);
                var signature = string.Join("\u001F", cycle);
                if(!emitted.Add(signature)) continue;
                cycle.Add(dependency);
                findings.Add(new Finding("AD104", FindingSeverity.Error, "Circular asset reference detected.", frame.Node, dependency, Details: string.Join(" → ", cycle)));
            }
        }
        return findings;

        void Push(string node)
        {
            states[node] = 1; positions[node] = active.Count; active.Add(node);
            var dependencies = graph.TryGetValue(node, out var values) && values != null ? new List<string>(values).ToArray() : Array.Empty<string>();
            Array.Sort(dependencies, StringComparer.OrdinalIgnoreCase);
            frames.Add(new Frame(node, dependencies));
        }
    }
}
mikekotys.assetdoctor / Editor/ProjectAssetScope.cs
Editor library
#nullable enable
using System;
using System.IO;

namespace AssetDoctor;

/// <summary>Limits scans to physical source files beneath the current project's Assets directory.</summary>
public sealed class ProjectAssetScope
{
    /// <summary>Stores the normalized project Assets directory.</summary>
    private readonly string _assetsRoot;

    /// <summary>Stores the platform-selected path comparison mode.</summary>
    private readonly StringComparison _pathComparison;

    /// <summary>Creates a scope for the active project, or returns null when its Assets path is unavailable or invalid.</summary>
    public static ProjectAssetScope? TryCreate()
    {
        var assetsPath = Project.Current?.GetAssetsPath();
        if(string.IsNullOrWhiteSpace(assetsPath)) return null;
        try { return new ProjectAssetScope(assetsPath); }
        catch(Exception exception) { Log.Warning($"Asset Doctor could not resolve project Assets path: {exception.GetType().Name}"); return null; }
    }

    /// <summary>Initializes a normalized project asset scope.</summary>
    private ProjectAssetScope(string assetsPath)
    {
        _assetsRoot = NormalizeDirectory(assetsPath);
        _pathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
    }

    /// <summary>Returns whether an editor asset belongs to the current project's source Assets directory.</summary>
    public bool Contains(Editor.Asset? asset)
    {
        if(asset == null || !asset.HasSourceFile || string.IsNullOrWhiteSpace(asset.AbsolutePath)) return false;
        try { return Path.GetFullPath(asset.AbsolutePath).StartsWith(_assetsRoot, _pathComparison); }
        catch(Exception exception) { Log.Warning($"Asset Doctor could not scope '{asset.Path}': {exception.GetType().Name}"); return false; }
    }

    /// <summary>Normalizes a directory with one trailing separator for safe prefix matching.</summary>
    private static string NormalizeDirectory(string path)
    {
        var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
        return fullPath + Path.DirectorySeparatorChar;
    }
}
mikekotys.assetdoctor / Editor/AssetDoctorWindow.cs
Editor library
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace AssetDoctor;

/// <summary>Provides the dockable UI for heuristic current-project asset diagnostics.</summary>
[Dock("Editor", "Asset Doctor", "local_hospital")]
public sealed class AssetDoctorWindow : Widget
{
    /// <summary>Limits total scan findings retained in memory and exported.</summary>
    private const int MaxScanFindings = 50_000;
    /// <summary>Limits rendered occurrences under one rule group.</summary>
    private const int MaxRenderedFindingsPerGroup = 250;
    /// <summary>Limits rendered rule groups to protect editor responsiveness.</summary>
    private const int MaxRenderedGroups = 200;
    /// <summary>Bounds memory used by dependency-graph edges.</summary>
    private const int MaxDependencyGraphEdges = 100_000;
    /// <summary>References the scrollable findings host.</summary>
    private ScrollArea? _scrollArea;
    /// <summary>References the current findings canvas.</summary>
    private Widget? _resultsContainer;
    /// <summary>Displays scan and export status.</summary>
    private Label? _statusLabel;
    /// <summary>References the report export button.</summary>
    private Button? _exportButton;
    /// <summary>Stores the cancellation source for the active scan.</summary>
    private CancellationTokenSource? _scanCts;
    /// <summary>Stores findings from the most recently completed scan attempt.</summary>
    private List<Finding> _lastFindings = new();
    /// <summary>Invalidates stale asynchronous scan completions.</summary>
    private int _scanGeneration;

    /// <summary>Initializes the dock content.</summary>
    public AssetDoctorWindow(Widget parent) : base(parent, false) => BuildUI();

    /// <summary>Builds fresh editor controls after creation or hotload.</summary>
    [EditorEvent.Hotload]
    private void BuildUI()
    {
        CancelCurrentScan();
        _scanGeneration++;
        if(Layout == null)
        {
            Layout = Layout.Column();
            Layout.Margin = 4;
            Layout.Spacing = 4;
        }
        else Layout.Clear(true);
        var scanButton = new Button("Run / Restart Scan", "search", this);
        scanButton.Clicked += OnScanClicked;
        scanButton.MinimumSize = new Vector2(220, 38);
        Layout.Add(scanButton);
        _exportButton = new Button("Export Reports", "download", this);
        _exportButton.Clicked += ExportReports;
        _exportButton.MinimumSize = new Vector2(220, 38);
        _exportButton.Hidden = _lastFindings.Count == 0;
        Layout.Add(_exportButton);
        _statusLabel = new Label(string.Empty, this) { Hidden = true };
        Layout.Add(_statusLabel);
        _scrollArea = new ScrollArea(this) { Hidden = true };
        Layout.Add(_scrollArea);
        RebuildResultsContainer();
        if(_lastFindings.Count > 0) RenderFindings(_lastFindings);
    }

    /// <summary>Starts a new scan and cancels any earlier scan.</summary>
    private async void OnScanClicked()
    {
        CancelCurrentScan();
        var cts = new CancellationTokenSource();
        _scanCts = cts;
        var generation = ++_scanGeneration;
        _lastFindings = new();
        if(_exportButton != null) _exportButton.Hidden = true;
        if(_scrollArea != null) _scrollArea.Hidden = true;
        if(_statusLabel != null) _statusLabel.Hidden = false;
        if(_resultsContainer?.Layout != null) _resultsContainer.Layout.Clear(true);
        SetStatus("Scanning current-project source assets...", "#ffffff");
        try
        {
            var snapshot = CreateSnapshot();
            var findings = await Task.Run(() => ScanFiles(snapshot, cts.Token), cts.Token);
            if(!IsValid || cts.IsCancellationRequested || generation != _scanGeneration) return;
            RenderFindings(findings);
        }
        catch(OperationCanceledException)
        {
            if(generation == _scanGeneration) SetStatus("Scan cancelled.", "#ffe19a");
        }
        catch(Exception exception)
        {
            Log.Error($"Asset Doctor scan failed: {exception}");
            if(generation == _scanGeneration) SetStatus($"Scan failed: {exception.GetType().Name}", "#ffb8c0");
        }
        finally
        {
            if(ReferenceEquals(_scanCts, cts)) _scanCts = null;
            cts.Dispose();
        }
    }

    /// <summary>Creates a UI-thread snapshot of project files and all editor-known resolvable logical paths.</summary>
    private static ScanSnapshot CreateSnapshot()
    {
        var scope = ProjectAssetScope.TryCreate();
        if(scope == null) throw new InvalidOperationException("No active project Assets directory is available.");
        var allEditorAssets = AssetSystem.All.Where(x => x != null && !string.IsNullOrWhiteSpace(x.Path)).ToArray();
        var resolvablePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach(var asset in allEditorAssets)
        {
            resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.Path));
            if(!string.IsNullOrWhiteSpace(asset.RelativePath))
                resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.RelativePath));
        }
        var projectAssets = allEditorAssets
            .Where(scope.Contains)
            .Where(x => !string.IsNullOrWhiteSpace(x.AbsolutePath))
            .Select(x => new SourceAsset(AssetPathRules.NormalizeSeparators(x.Path), x.AbsolutePath))
            .ToArray();
        return new ScanSnapshot(projectAssets, resolvablePaths);
    }

    /// <summary>Scans physical source files on a worker thread without s&box interop calls.</summary>
    private static List<Finding> ScanFiles(ScanSnapshot snapshot, CancellationToken token)
    {
        var collector = new FindingCollector(MaxScanFindings);
        var projectPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach(var asset in snapshot.Assets)
        {
            token.ThrowIfCancellationRequested();
            if(projectPaths.TryGetValue(asset.Path, out var previous))
            {
                if(!collector.TryAdd(new Finding("AD110", FindingSeverity.Error, "Asset paths collide when compared without letter case.", asset.Path, previous)))
                    return collector.Findings;
            }
            else projectPaths.Add(asset.Path, asset.Path);
        }

        var graph = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
        var graphEdgeCount = 0;
        var graphLimitReported = false;
        foreach(var asset in snapshot.Assets)
        {
            token.ThrowIfCancellationRequested();
            if(!AssetPathRules.HasAnyExtension(asset.Path, AssetPathRules.TextAssetExtensions)) continue;
            try
            {
                var info = new FileInfo(asset.AbsolutePath);
                if(!info.Exists)
                {
                    if(!collector.TryAdd(new Finding("AD105", FindingSeverity.Error, "Could not inspect text asset because its source file no longer exists.", asset.Path)))
                        return collector.Findings;
                    continue;
                }
                if(info.Length == 0) continue;
                if(info.Length > AssetPathRules.MaxTextFileBytes)
                {
                    if(!collector.TryAdd(new Finding("AD112", FindingSeverity.Warning, "Text asset was skipped because it exceeds the physical file-size scan limit.", asset.Path)))
                        return collector.Findings;
                    continue;
                }

                var text = File.ReadAllText(asset.AbsolutePath);
                if(text.Length > AssetPathRules.MaxTextCharacters)
                {
                    if(!collector.TryAdd(new Finding("AD112", FindingSeverity.Warning, "Text asset was skipped because it exceeds the decoded character scan limit.", asset.Path)))
                        return collector.Findings;
                    continue;
                }

                var extraction = ReferenceExtractor.Extract(text, AssetPathRules.UsesJsonEscapes(asset.Path), token);
                if(extraction.IsTruncated && !collector.TryAdd(new Finding("AD113", FindingSeverity.Warning, "Reference extraction stopped after reaching the per-file reference limit.", asset.Path)))
                    return collector.Findings;

                foreach(var reference in extraction.References)
                {
                    token.ThrowIfCancellationRequested();
                    try
                    {
                        var invalid = AssetPathValidator.Validate(asset.Path, reference.Path, reference.Line);
                        if(invalid != null)
                        {
                            if(!collector.TryAdd(invalid)) return collector.Findings;
                            continue;
                        }

                        if(projectPaths.TryGetValue(reference.Path, out var actualProjectPath))
                        {
                            if(!string.Equals(reference.Path, actualProjectPath, StringComparison.Ordinal) &&
                                !collector.TryAdd(new Finding("AD103", FindingSeverity.Error, "Asset reference uses different letter case than the actual asset path.", asset.Path, reference.Path, reference.Line, actualProjectPath)))
                                return collector.Findings;

                            if(!graph.TryGetValue(asset.Path, out var dependencies))
                            {
                                dependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                                graph.Add(asset.Path, dependencies);
                            }

                            if(!dependencies.Contains(actualProjectPath))
                            {
                                if(graphEdgeCount < MaxDependencyGraphEdges)
                                {
                                    dependencies.Add(actualProjectPath);
                                    graphEdgeCount++;
                                }
                                else if(!graphLimitReported)
                                {
                                    if(!collector.TryAdd(new Finding(
                                        "AD115",
                                        FindingSeverity.Warning,
                                        "Dependency graph limit was reached. Circular-reference results may be incomplete.",
                                        asset.Path)))
                                    {
                                        return collector.Findings;
                                    }

                                    graphLimitReported = true;
                                }
                            }
                        }
                        else if(!snapshot.ResolvablePaths.Contains(reference.Path))
                        {
                            if(!collector.TryAdd(new Finding("AD100", FindingSeverity.Error, "Missing asset reference.", asset.Path, reference.Path, reference.Line)))
                                return collector.Findings;
                        }
                    }
                    catch(Exception exception)
                    {
                        if(!collector.TryAdd(new Finding("AD111", FindingSeverity.Error, $"Could not validate asset reference ({exception.GetType().Name}).", asset.Path, reference.Path, reference.Line)))
                            return collector.Findings;
                    }
                }
            }
            catch(Exception exception)
            {
                if(!collector.TryAdd(new Finding("AD105", FindingSeverity.Error, $"Could not read text asset ({exception.GetType().Name}).", asset.Path)))
                    return collector.Findings;
            }
        }

        foreach(var cycleFinding in AssetReferenceCycleDetector.Find(graph, token))
        {
            if(!collector.TryAdd(cycleFinding)) break;
        }
        return collector.Findings;
    }

    /// <summary>Renders bounded groups from completed findings.</summary>
    private void RenderFindings(List<Finding> findings)
    {
        if(_resultsContainer?.Layout == null) return;
        _lastFindings = findings;
        _resultsContainer.Layout.Clear(true);
        if(_scrollArea != null)
        {
            _scrollArea.Hidden = false;
            _scrollArea.MinimumSize = new Vector2(0, 280);
        }
        if(_exportButton != null) _exportButton.Hidden = findings.Count == 0;
        var errors = findings.Count(x => x.Severity == FindingSeverity.Error);
        var warnings = findings.Count(x => x.Severity == FindingSeverity.Warning);
        SetStatus($"Found {findings.Count} issues · {errors} errors · {warnings} warnings · heuristic quoted-path scan", errors > 0 ? "#ffb8c0" : "#9ee6a5");
        var groups = findings.GroupBy(x => new { x.RuleId, x.Severity, x.Message }).OrderByDescending(x => Rank(x.Key.Severity)).ThenBy(x => x.Key.RuleId).Take(MaxRenderedGroups).ToArray();
        foreach(var group in groups) AddGroup(group.Key.RuleId, group.Key.Severity, group.Key.Message, group);
        if(findings.Count > 0 && groups.Length == MaxRenderedGroups) _resultsContainer.Layout.Add(new Label("More rule groups were omitted to protect Editor performance. Export reports for the full list.", _resultsContainer));
        _resultsContainer.Layout.AddStretchCell();
    }

    /// <summary>Adds one collapsible rule group to the results canvas.</summary>
    private void AddGroup(string ruleId, FindingSeverity severity, string message, IEnumerable<Finding> findings)
    {
        if(_resultsContainer?.Layout == null) return;
        var all = findings.OrderBy(x => x.SourcePath, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.ReferencedPath, StringComparer.OrdinalIgnoreCase).ToArray();
        var rendered = all.Take(MaxRenderedFindingsPerGroup).ToArray();
        var isError = severity == FindingSeverity.Error;
        var background = isError ? "#4a1f25" : "#4a3a12";
        var border = isError ? "#d94b58" : "#e0ae32";
        var text = isError ? "#ffb8c0" : "#ffe19a";
        var container = new Widget(_resultsContainer) { Layout = Layout.Column() };
        container.Layout.Margin = 0;
        container.Layout.Spacing = 1;
        _resultsContainer.Layout.Add(container);
        var closed = $"▶ {ruleId} · {message} · {all.Length} issue(s)";
        var open = $"▼ {ruleId} · {message} · {all.Length} issue(s)";
        var details = new Widget(container) { Layout = Layout.Column(), Hidden = true };
        details.Layout.Margin = 0;
        details.Layout.Spacing = 1;
        var header = new FindingRow(container, closed, background, border, text);
        header.Clicked += () =>
        {
            if(!details.IsValid || !header.IsValid) return;
            details.Hidden = !details.Hidden;
            header.Text = details.Hidden ? closed : open;
        };
        container.Layout.Add(header);
        foreach(var finding in rendered)
        {
            var source = finding.SourcePath;
            var holder = new Widget(details) { Layout = Layout.Row() };
            holder.Layout.Margin = 0;
            holder.Layout.Spacing = 0;
            holder.Layout.Add(new Widget(holder) { FixedWidth = 18 });
            var detail = finding.Details == null ? string.Empty : $" · {finding.Details}";
            var line = finding.Line.HasValue ? $":{finding.Line.Value}" : string.Empty;
            var row = new FindingRow(holder, $"{finding.RuleId} · {finding.Message} · {finding.SourcePath}{line} → {finding.ReferencedPath}{detail}", background, border, text);
            row.Clicked += () => AssetBrowserNavigator.FocusAsset(source);
            holder.Layout.Add(row, 1);
            details.Layout.Add(holder);
        }
        if(all.Length > rendered.Length) details.Layout.Add(new Label($"… {all.Length - rendered.Length} more issue(s); export reports for the full list.", details));
        container.Layout.Add(details);
    }

    /// <summary>Exports reports through a user-selected directory.</summary>
    private void ExportReports()
    {
        if(_lastFindings.Count == 0) return;
        var dialog = new FileDialog(this) { Title = "Select Report Folder" };
        dialog.SetFindDirectory();
        if(!dialog.Execute() || string.IsNullOrWhiteSpace(dialog.Directory)) return;
        try
        {
            var name = Path.GetFileName(Project.Current?.GetAssetsPath()?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) ?? "project";
            AssetDoctorReportExporter.Export(dialog.Directory, name, _lastFindings);
            SetStatus($"Reports exported to {dialog.Directory}", "#9ee6a5");
        }
        catch(Exception exception)
        {
            Log.Error($"Asset Doctor export failed: {exception}");
            SetStatus($"Export failed: {exception.GetType().Name}", "#ffb8c0");
        }
    }

    /// <summary>Creates the scroll canvas used for results.</summary>
    private void RebuildResultsContainer()
    {
        if(_scrollArea == null) return;
        _resultsContainer = new Widget(null) { Layout = Layout.Column() };
        _resultsContainer.Layout.Spacing = 2;
        _scrollArea.Canvas = _resultsContainer;
    }

    /// <summary>Cancels and detaches the active scan; its owner disposes the source after completion.</summary>
    private void CancelCurrentScan()
    {
        var current = Interlocked.Exchange(ref _scanCts, null);
        current?.Cancel();
    }

    /// <summary>Updates status text and its severity color.</summary>
    private void SetStatus(string text, string color)
    {
        if(_statusLabel == null) return;
        _statusLabel.Hidden = false;
        _statusLabel.Text = text;
        _statusLabel.SetStyles($"padding: 4px 2px; color: {color};");
    }

    /// <summary>Returns sort precedence for severities.</summary>
    private static int Rank(FindingSeverity severity) => severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;
    /// <summary>Bounds retained findings and adds one explicit truncation diagnostic when the scan limit is reached.</summary>
    private sealed class FindingCollector
    {
        /// <summary>Stores retained findings.</summary>
        private readonly List<Finding> _findings = new();
        /// <summary>Stores the maximum retained finding count.</summary>
        private readonly int _limit;
        /// <summary>Tracks whether the terminal limit finding was added.</summary>
        private bool _truncated;

        /// <summary>Initializes a bounded finding collector.</summary>
        public FindingCollector(int limit) => _limit = limit;
        /// <summary>Gets retained findings.</summary>
        public List<Finding> Findings => _findings;
        /// <summary>Adds a finding or a final truncation diagnostic, returning false when scanning must stop.</summary>
        public bool TryAdd(Finding finding)
        {
            if(_truncated) return false;
            if(_findings.Count < _limit - 1)
            {
                _findings.Add(finding);
                return true;
            }

            _findings.Add(new Finding("AD114", FindingSeverity.Warning, "Scan stopped after reaching the global finding limit.", finding.SourcePath, finding.ReferencedPath, finding.Line));
            _truncated = true;
            return false;
        }
    }

    /// <summary>Represents a physical source file captured on the UI thread.</summary>
    private sealed record SourceAsset(string Path, string AbsolutePath);
    /// <summary>Represents a scan-input snapshot that worker code treats as read-only.</summary>
    private sealed record ScanSnapshot(SourceAsset[] Assets, HashSet<string> ResolvablePaths);
}
mikekotys.assetdoctor / Editor/FindingRow.cs
Editor library
#nullable enable
using System;

namespace AssetDoctor;

/// <summary>Provides a styled clickable row without native button rendering.</summary>
public sealed class FindingRow : Widget
{
    /// <summary>Displays the row text.</summary>
    private readonly Label _label;
    /// <summary>Raised when the row receives a mouse click.</summary>
    public event Action? Clicked;
    /// <summary>Gets or sets the visible row text.</summary>
    public string Text { get => _label.Text; set => _label.Text = value; }
    /// <summary>Creates a styled row using caller-supplied colors.</summary>
    public FindingRow(Widget parent, string text, string background, string border, string color) : base(parent)
    {
        MinimumSize = new Vector2(0, 24); Layout = Layout.Row(); Layout.Margin = 0; Layout.Spacing = 0;
        SetStyles($"background-color: {background}; border: 1px solid {border};");
        _label = new Label(text, this) { TransparentForMouseEvents = true };
        _label.SetStyles($"color: {color}; padding: 0px 6px; background-color: transparent;"); Layout.Add(_label);
        MouseClick += OnMouseClick;
    }
    /// <summary>Raises the row click event.</summary>
    private void OnMouseClick() => Clicked?.Invoke();
}
Debug: View Raw JSON Response
{
    "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/// <summary>Focuses an editor-known asset in its Asset Browser view.</summary>\r\npublic static class AssetBrowserNavigator\r\n{\r\n    /// <summary>Attempts to focus an asset by logical path and falls back to highlighting the path.</summary>\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 =>\r\n            item != null && string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase));\r\n        if(asset == null)\r\n        {\r\n            EditorEvent.Run(\"assetsystem.highlight\", path);\r\n            Log.Warning($\"Asset Doctor could not select '{path}'; sent an Asset Browser highlight instead.\");\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($\"Asset Doctor could not locate an Asset Browser view for '{path}'.\");\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/// <summary>Writes timestamped Markdown, text, and JSON reports for completed heuristic scans.</summary>\r\npublic static class AssetDoctorReportExporter\r\n{\r\n    /// <summary>Exports all report formats with one timestamp and attempts to remove partial final files on failure.</summary>\r\n    public static IReadOnlyList<string> Export(string directory, string projectName, IReadOnlyCollection<Finding> findings)\r\n    {\r\n        if(string.IsNullOrWhiteSpace(directory)) throw new ArgumentException(\"A report directory is required.\", 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(\"yyyyMMdd-HHmmss-fff\", CultureInfo.InvariantCulture);\r\n        var safeProject = SanitizeFileName(string.IsNullOrWhiteSpace(projectName) ? \"project\" : projectName);\r\n        var unique = Guid.NewGuid().ToString(\"N\")[..8];\r\n        var prefix = $\"asset_doctor_{safeProject}_{stamp}_{unique}\";\r\n        var ordered = findings.OrderByDescending(x => Rank(x.Severity)).ThenBy(x => x.RuleId, StringComparer.Ordinal).ThenBy(x => x.SourcePath, StringComparer.Ordinal).ToArray();\r\n        var finals = new[] { Path.Combine(directory, prefix + \".md\"), Path.Combine(directory, prefix + \".txt\"), Path.Combine(directory, prefix + \".json\") };\r\n        var temps = finals.Select(path => path + \".tmp-\" + Guid.NewGuid().ToString(\"N\")).ToArray();\r\n        var moved = new List<string>();\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 < finals.Length; index++)\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    /// <summary>Writes a Markdown report without building an additional full report string in memory.</summary>\r\n    private static void WriteMarkdown(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\"# Asset Doctor Report\");\r\n        writer.WriteLine();\r\n        writer.WriteLine($\"Generated: {generatedAt:O}\");\r\n        writer.WriteLine(\"Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.\");\r\n        writer.WriteLine($\"Issues: {findings.Count}\");\r\n        writer.WriteLine();\r\n        foreach(var finding in findings)\r\n        {\r\n            writer.WriteLine($\"## {EscapeMarkdown(finding.RuleId)} \u00b7 {finding.Severity}\");\r\n            writer.WriteLine();\r\n            writer.WriteLine(EscapeMarkdown(finding.Message));\r\n            writer.WriteLine($\"- Source: {Code(finding.SourcePath)}\");\r\n            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($\"- Reference: {Code(finding.ReferencedPath)}\");\r\n            if(finding.Line.HasValue) writer.WriteLine($\"- Line: {finding.Line.Value}\");\r\n            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($\"- Details: {Code(finding.Details)}\");\r\n            writer.WriteLine();\r\n        }\r\n    }\r\n\r\n    /// <summary>Writes a plain-text report without building an additional full report string in memory.</summary>\r\n    private static void WriteText(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\"ASSET DOCTOR REPORT\");\r\n        writer.WriteLine($\"Generated: {generatedAt:O}\");\r\n        writer.WriteLine(\"Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.\");\r\n        writer.WriteLine($\"Issues: {findings.Count}\");\r\n        writer.WriteLine();\r\n        foreach(var finding in findings)\r\n        {\r\n            writer.WriteLine($\"{finding.RuleId} \u00b7 {finding.Severity} \u00b7 {Plain(finding.Message)}\");\r\n            writer.WriteLine($\"Source: {Plain(finding.SourcePath)}\");\r\n            if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($\"Reference: {Plain(finding.ReferencedPath)}\");\r\n            if(finding.Line.HasValue) writer.WriteLine($\"Line: {finding.Line.Value}\");\r\n            if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($\"Details: {Plain(finding.Details)}\");\r\n            writer.WriteLine();\r\n        }\r\n    }\r\n\r\n    /// <summary>Writes a machine-readable JSON report without requiring external serializer packages.</summary>\r\n    private static void WriteJson(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)\r\n    {\r\n        using var writer = CreateWriter(path);\r\n        writer.WriteLine(\"{\");\r\n        writer.WriteLine($\"  \\\"generatedAt\\\": {Json(generatedAt.ToString(\"O\", CultureInfo.InvariantCulture))},\");\r\n        writer.WriteLine(\"  \\\"detectionMode\\\": \\\"heuristic quoted-path scan; not a complete dependency graph\\\",\");\r\n        writer.WriteLine(\"  \\\"findings\\\": [\");\r\n        for(var index = 0; index < findings.Count; index++)\r\n        {\r\n            var finding = findings[index];\r\n            writer.Write(\"    { \\\"ruleId\\\": \"); writer.Write(Json(finding.RuleId));\r\n            writer.Write(\", \\\"severity\\\": \"); writer.Write(Json(finding.Severity.ToString()));\r\n            writer.Write(\", \\\"message\\\": \"); writer.Write(Json(finding.Message));\r\n            writer.Write(\", \\\"sourcePath\\\": \"); writer.Write(Json(finding.SourcePath));\r\n            writer.Write(\", \\\"referencedPath\\\": \"); writer.Write(Json(finding.ReferencedPath));\r\n            writer.Write(\", \\\"line\\\": \"); writer.Write(finding.Line?.ToString(CultureInfo.InvariantCulture) ?? \"null\");\r\n            writer.Write(\", \\\"details\\\": \"); writer.Write(Json(finding.Details));\r\n            writer.Write(\" }\");\r\n            if(index + 1 < findings.Count) writer.Write(',');\r\n            writer.WriteLine();\r\n        }\r\n        writer.WriteLine(\"  ]\");\r\n        writer.WriteLine(\"}\");\r\n    }\r\n\r\n    /// <summary>Creates a UTF-8 writer for one temporary report.</summary>\r\n    private static StreamWriter CreateWriter(string path) => new(path, false, new UTF8Encoding(false));\r\n\r\n    /// <summary>Escapes a JSON string including control characters.</summary>\r\n    private static string Json(string? value)\r\n    {\r\n        if(value == null) return \"null\";\r\n        var builder = new StringBuilder(value.Length + 2).Append('\"');\r\n        foreach(var character in value)\r\n        {\r\n            switch(character)\r\n            {\r\n                case '\\\\': builder.Append(\"\\\\\\\\\"); break;\r\n                case '\"': builder.Append(\"\\\\\\\"\"); break;\r\n                case '\\b': builder.Append(\"\\\\b\"); break;\r\n                case '\\f': builder.Append(\"\\\\f\"); break;\r\n                case '\\n': builder.Append(\"\\\\n\"); break;\r\n                case '\\r': builder.Append(\"\\\\r\"); break;\r\n                case '\\t': builder.Append(\"\\\\t\"); break;\r\n                default:\r\n                    if(character < ' ') builder.Append($\"\\\\u{(int)character:X4}\");\r\n                    else builder.Append(character);\r\n                    break;\r\n            }\r\n        }\r\n        return builder.Append('\"').ToString();\r\n    }\r\n\r\n    /// <summary>Escapes Markdown characters that could alter report structure.</summary>\r\n    private static string EscapeMarkdown(string? value) => Plain(value).Replace(\"\\\\\", \"\\\\\\\\\").Replace(\"`\", \"\\\\`\").Replace(\"*\", \"\\\\*\").Replace(\"_\", \"\\\\_\").Replace(\"[\", \"\\\\[\").Replace(\"]\", \"\\\\]\").Replace(\"#\", \"\\\\#\").Replace(\"|\", \"\\\\|\").Replace(\"!\", \"\\\\!\").Replace(\"~\", \"\\\\~\").Replace(\"<\", \"&lt;\").Replace(\">\", \"&gt;\");\r\n\r\n    /// <summary>Uses a variable-length inline-code delimiter so embedded backticks remain literal.</summary>\r\n    private static string Code(string? value)\r\n    {\r\n        var text = Plain(value);\r\n        var fence = \"`\";\r\n        while(text.Contains(fence, StringComparison.Ordinal)) fence += \"`\";\r\n        return fence + text + fence;\r\n    }\r\n\r\n    /// <summary>Renders selected control and directional characters visibly to reduce report spoofing.</summary>\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 == '\\u202E' ? $\"\\\\u{(int)character:X4}\" : character);\r\n        return builder.ToString();\r\n    }\r\n\r\n    /// <summary>Replaces filename characters that are invalid on the current platform.</summary>\r\n    private static string SanitizeFileName(string value) => string.Concat(value.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character));\r\n\r\n    /// <summary>Deletes temporary or rolled-back files without masking the primary export error.</summary>\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    /// <summary>Returns a deterministic severity sort rank.</summary>\r\n    private static int Rank(FindingSeverity severity) => 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/// <summary>Adds Asset Doctor direct-link actions to the Asset Browser context menu.</summary>\r\npublic static class AssetContextMenuActions\r\n{\r\n    /// <summary>Stores the nested menu path for reverse dependency lookup.</summary>\r\n    private static readonly string[] FindDependantsMenuPath = { \"Asset Doctor\", \"Find Assets Using This\" };\r\n    /// <summary>Stores the nested menu path for forward dependency lookup.</summary>\r\n    private static readonly string[] FindReferencesMenuPath = { \"Asset Doctor\", \"Find Assets Used By This\" };\r\n\r\n    /// <summary>Registers direct-link actions when exactly one valid Asset Browser entry is selected.</summary>\r\n    [Event(\"asset.contextmenu\")]\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 += () =>\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                \"manage_search\",\r\n                () => new AssetLinksWindow(asset, true),\r\n                \"Show assets that directly use this asset\");\r\n\r\n            context.Menu.AddOption(\r\n                FindReferencesMenuPath,\r\n                \"account_tree\",\r\n                () => new AssetLinksWindow(asset, false),\r\n                \"Show assets directly used by this asset\");\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/// <summary>Describes the severity assigned to a validation result.</summary>\r\npublic enum FindingSeverity { Info, Warning, Error }\r\n\r\n/// <summary>Represents one diagnostic produced by a scan.</summary>\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/// <summary>Stores shared asset-path constants and normalization helpers.</summary>\r\npublic static class AssetPathRules\r\n{\r\n    /// <summary>Limits an individual quoted candidate to prevent pathological scans.</summary>\r\n    public const int MaxReferenceLength = 4096;\r\n    /// <summary>Limits extracted references from one source file to protect scan memory.</summary>\r\n    public const int MaxReferencesPerSourceFile = 10_000;\r\n    /// <summary>Limits physical source-file bytes before allocating text in memory.</summary>\r\n    public const long MaxTextFileBytes = 4_000_000;\r\n    /// <summary>Limits decoded source characters after a successful read.</summary>\r\n    public const int MaxTextCharacters = 4_000_000;\r\n    /// <summary>Lists source formats whose quoted values are currently scanned heuristically.</summary>\r\n    public static readonly string[] TextAssetExtensions = { \".scene\", \".prefab\", \".vmdl\", \".vmat\", \".vtex\", \".sound\", \".surface\", \".clothing\", \".decal\", \".vmap\", \".vfx\", \".vanmgrph\", \".vpost\", \".shader\", \".shdrgrph\", \".vpcf\", \".json\" };\r\n    /// <summary>Lists referenced asset extensions recognized inside quoted source values.</summary>\r\n    public static readonly string[] ReferenceExtensions = { \".scene\", \".prefab\", \".vmdl\", \".vmat\", \".vtex\", \".sound\", \".surface\", \".clothing\", \".decal\", \".vmap\", \".vfx\", \".vanmgrph\", \".vpost\", \".shader\", \".shdrgrph\", \".vpcf\", \".png\", \".jpg\", \".jpeg\", \".tga\", \".fbx\", \".json\" };\r\n    /// <summary>Returns whether a path ends in one of the supplied extensions.</summary>\r\n    public static bool HasAnyExtension(string path, IReadOnlyList<string> extensions)\r\n    {\r\n        if(string.IsNullOrEmpty(path)) return false;\r\n        for(var index = 0; index < extensions.Count; index++) if(path.EndsWith(extensions[index], StringComparison.OrdinalIgnoreCase)) return true;\r\n        return false;\r\n    }\r\n    /// <summary>Converts one or more Windows separators without removing unsafe whitespace.</summary>\r\n    public static string NormalizeSeparators(string path) => (path ?? string.Empty).Replace('\\\\', '/');\r\n    /// <summary>Returns whether references in this source format should use JSON-style escape decoding.</summary>\r\n    public static bool UsesJsonEscapes(string path) => path.EndsWith(\".json\", StringComparison.OrdinalIgnoreCase) || path.EndsWith(\".prefab\", StringComparison.OrdinalIgnoreCase) || path.EndsWith(\".scene\", StringComparison.OrdinalIgnoreCase);\r\n}\r\n\r\n/// <summary>Represents one extracted asset path and its one-based source line.</summary>\r\npublic sealed record AssetReference(string Path, int Line);\r\n\r\n/// <summary>Contains bounded reference extraction output and indicates whether the per-file limit was reached.</summary>\r\npublic sealed record ReferenceExtractionResult(IReadOnlyList<AssetReference> References, bool IsTruncated);\r\n\r\n/// <summary>Extracts quoted asset paths with bounded linear-time scanning.</summary>\r\npublic static class ReferenceExtractor\r\n{\r\n    /// <summary>Extracts recognized references from text; output is heuristic because only quoted values are examined.</summary>\r\n    public static ReferenceExtractionResult Extract(string? text, bool decodeJsonEscapes, CancellationToken token = default)\r\n    {\r\n        var results = new List<AssetReference>();\r\n        if(string.IsNullOrEmpty(text)) return new ReferenceExtractionResult(results, false);\r\n        var line = 1;\r\n        for(var index = 0; index < text.Length; index++)\r\n        {\r\n            if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();\r\n            if(text[index] == '\\n') { line++; continue; }\r\n            var quote = text[index];\r\n            if(quote != '\\'' && quote != '\"') continue;\r\n            var startLine = line;\r\n            var start = ++index;\r\n            var escaped = false;\r\n            var tooLong = false;\r\n            var closed = false;\r\n            while(index < text.Length)\r\n            {\r\n                if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();\r\n                var character = text[index];\r\n                if(character == '\\n') line++;\r\n                if(character == quote && !escaped) { closed = true; break; }\r\n                if(index - start >= AssetPathRules.MaxReferenceLength) tooLong = true;\r\n                escaped = character == '\\\\' ? !escaped : false;\r\n                index++;\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 >= AssetPathRules.MaxReferencesPerSourceFile)\r\n                return new ReferenceExtractionResult(results, true);\r\n        }\r\n        return new ReferenceExtractionResult(results, false);\r\n    }\r\n\r\n    /// <summary>Decodes JSON escapes only for JSON-like source formats and preserves ordinary backslash paths otherwise.</summary>\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 < raw.Length; index++)\r\n        {\r\n            var character = raw[index];\r\n            if(character != '\\\\') { builder.Append(character); continue; }\r\n            if(++index >= raw.Length) { value = string.Empty; return false; }\r\n            switch(raw[index])\r\n            {\r\n                case '\"': builder.Append('\"'); break;\r\n                case '\\\\': builder.Append('\\\\'); break;\r\n                case '/': builder.Append('/'); break;\r\n                case 'b': builder.Append('\\b'); break;\r\n                case 'f': builder.Append('\\f'); break;\r\n                case 'n': builder.Append('\\n'); break;\r\n                case 'r': builder.Append('\\r'); break;\r\n                case 't': builder.Append('\\t'); break;\r\n                case 'u' when index + 4 < raw.Length:\r\n                    var hex = raw.Substring(index + 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 += 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( \"AddonTitle\", \"Asset Doctor\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"assetdoctor\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"mikekotys\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"mikekotys.assetdoctor\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-07-31T09:27:02.2172776Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.113.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.113.0\")]"
        },
        {
            "Ident": "mikekotys.assetdoctor",
            "Path": "Editor/Assembly.cs",
            "FileName": "Assembly.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337738,
            "Code": "// Shared editor namespaces for this single s&box 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/// <summary>Validates that extracted references are safe portable asset paths.</summary>\r\npublic static class AssetPathValidator\r\n{\r\n    /// <summary>Returns the highest-priority finding for a reference, or null when no issue is detected.</summary>\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(\"AD109\", FindingSeverity.Warning, \"Asset path has leading or trailing whitespace.\", sourcePath, referencedPath, line);\r\n            var path = AssetPathRules.NormalizeSeparators(referencedPath);\r\n            foreach(var character in path) if(char.IsControl(character) || character == '\\u202E') return New(\"AD101\", FindingSeverity.Error, \"Asset path contains unsafe control or directional characters.\", sourcePath, referencedPath, line);\r\n            if(path.StartsWith(\"mount://\", StringComparison.OrdinalIgnoreCase)) return New(\"AD102\", FindingSeverity.Error, \"Mounted assets cannot be included in a published package.\", sourcePath, referencedPath, line);\r\n            if(path.Contains(\"://\", StringComparison.Ordinal)) return New(\"AD106\", FindingSeverity.Error, \"External URI used where a project-relative asset path is expected.\", sourcePath, referencedPath, line);\r\n            var drivePath = path.Length >= 3 && ((path[0] is >= 'A' and <= 'Z') || (path[0] is >= 'a' and <= 'z')) && path[1] == ':' && path[2] == '/';\r\n            if(path.StartsWith(\"/\", StringComparison.Ordinal) || drivePath || path.IndexOf(':') >= 0) return New(\"AD107\", FindingSeverity.Error, \"Absolute or drive-relative asset paths are not portable.\", sourcePath, referencedPath, line);\r\n            var decoded = TryPercentDecode(path);\r\n            foreach(var segment in decoded.Split('/')) if(segment == \"..\") return New(\"AD108\", FindingSeverity.Error, \"Parent-directory traversal is not allowed in asset paths.\", sourcePath, referencedPath, line);\r\n            if(path.StartsWith(\"./\", StringComparison.Ordinal) || path.Contains(\"//\", StringComparison.Ordinal) || path.Contains(\"/./\", StringComparison.Ordinal) || !path.IsNormalized(NormalizationForm.FormC)) return New(\"AD109\", FindingSeverity.Warning, \"Asset path is not canonical.\", 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(\"AD101\", FindingSeverity.Error, \"Asset path contains invalid Unicode or encoding.\", sourcePath, referencedPath ?? string.Empty, line);\r\n        }\r\n    }\r\n\r\n    /// <summary>Attempts to decode percent escapes for traversal detection without changing the reported original path.</summary>\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    /// <summary>Creates a finding with consistent source-location metadata.</summary>\r\n    private static Finding New(string ruleId, FindingSeverity severity, string message, string source, string reference, int? line) => 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/// <summary>Shows direct native s&box references or dependants for one selected asset.</summary>\npublic sealed class AssetLinksWindow : Widget\n{\n    /// <summary>Limits synchronous row creation while retaining the true lookup count.</summary>\n    private const int MaxRenderedAssets = 500;\n\n    /// <summary>Creates and displays a compact direct-link inspector.</summary>\n    public AssetLinksWindow(Asset asset, bool showDependants) : base(null)\n    {\n        if(asset == null) throw new ArgumentNullException(nameof(asset));\n        WindowTitle = showDependants ? \"Find Assets Using This\" : \"Find Assets Used By This\";\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 ? $\"Assets directly using: {asset.Path}\" : $\"Assets directly used by: {asset.Path}\", this));\n        Asset[] allLinks;\n        try\n        {\n            allLinks = (showDependants ? asset.GetDependants(false) : asset.GetReferences(false))?\n                .Where(x => x != null && !string.IsNullOrWhiteSpace(x.Path))\n                .GroupBy(x => x.Path, StringComparer.OrdinalIgnoreCase)\n                .Select(x => x.First())\n                .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)\n                .ToArray() ?? Array.Empty<Asset>();\n        }\n        catch(Exception exception)\n        {\n            Log.Error($\"Asset Doctor link lookup failed: {exception}\");\n            Layout.Add(new Label($\"Lookup failed: {exception.GetType().Name}\", this));\n            Show();\n            return;\n        }\n        var renderedLinks = allLinks.Take(MaxRenderedAssets).ToArray();\n        Layout.Add(new Label($\"Found {allLinks.Length} direct asset(s)\", 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, \"#1d2c3a\", \"#3a6d8f\", \"#c7e8ff\");\n            row.Clicked += () => AssetBrowserNavigator.FocusAsset(target.Path);\n            content.Layout.Add(row);\n        }\n        if(allLinks.Length == 0) content.Layout.Add(new Label(\"No direct asset links found.\", content));\n        if(allLinks.Length > renderedLinks.Length) content.Layout.Add(new Label($\"\u2026 {allLinks.Length - renderedLinks.Length} more asset(s) were not rendered to protect Editor responsiveness.\", 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/// <summary>Finds direct-reference cycles using an iterative depth-first traversal.</summary>\r\npublic static class AssetReferenceCycleDetector\r\n{\r\n    /// <summary>Represents the traversal state for one active graph node.</summary>\r\n    private sealed class Frame\r\n    {\r\n        /// <summary>Initializes a frame with the supplied dependency list.</summary>\r\n        public Frame(string node, string[] dependencies) { Node = node; Dependencies = dependencies; }\r\n        /// <summary>Gets the active node path.</summary>\r\n        public string Node { get; }\r\n        /// <summary>Gets dependencies to visit.</summary>\r\n        public string[] Dependencies { get; }\r\n        /// <summary>Gets or sets the next dependency index.</summary>\r\n        public int NextIndex { get; set; }\r\n    }\r\n\r\n    /// <summary>Finds cycles in a graph and returns a single finding per back-edge path.</summary>\r\n    public static List<Finding> Find(IReadOnlyDictionary<string, HashSet<string>> graph, CancellationToken token = default)\r\n    {\r\n        if(graph == null) throw new ArgumentNullException(nameof(graph));\r\n        var findings = new List<Finding>();\r\n        var states = new Dictionary<string, byte>(StringComparer.OrdinalIgnoreCase);\r\n        var active = new List<string>();\r\n        var positions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);\r\n        var frames = new List<Frame>();\r\n        var emitted = new HashSet<string>(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 > 0)\r\n            {\r\n                token.ThrowIfCancellationRequested();\r\n                var frame = frames[^1];\r\n                if(frame.NextIndex >= 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++];\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(\"\\u001F\", cycle);\r\n                if(!emitted.Add(signature)) continue;\r\n                cycle.Add(dependency);\r\n                findings.Add(new Finding(\"AD104\", FindingSeverity.Error, \"Circular asset reference detected.\", frame.Node, dependency, Details: string.Join(\" \u2192 \", 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) && values != null ? new List<string>(values).ToArray() : Array.Empty<string>();\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/// <summary>Limits scans to physical source files beneath the current project's Assets directory.</summary>\r\npublic sealed class ProjectAssetScope\r\n{\r\n    /// <summary>Stores the normalized project Assets directory.</summary>\r\n    private readonly string _assetsRoot;\r\n\r\n    /// <summary>Stores the platform-selected path comparison mode.</summary>\r\n    private readonly StringComparison _pathComparison;\r\n\r\n    /// <summary>Creates a scope for the active project, or returns null when its Assets path is unavailable or invalid.</summary>\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($\"Asset Doctor could not resolve project Assets path: {exception.GetType().Name}\"); return null; }\r\n    }\r\n\r\n    /// <summary>Initializes a normalized project asset scope.</summary>\r\n    private ProjectAssetScope(string assetsPath)\r\n    {\r\n        _assetsRoot = NormalizeDirectory(assetsPath);\r\n        _pathComparison = Path.DirectorySeparatorChar == '\\\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;\r\n    }\r\n\r\n    /// <summary>Returns whether an editor asset belongs to the current project's source Assets directory.</summary>\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($\"Asset Doctor could not scope '{asset.Path}': {exception.GetType().Name}\"); return false; }\r\n    }\r\n\r\n    /// <summary>Normalizes a directory with one trailing separator for safe prefix matching.</summary>\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 + 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/// <summary>Provides the dockable UI for heuristic current-project asset diagnostics.</summary>\r\n[Dock(\"Editor\", \"Asset Doctor\", \"local_hospital\")]\r\npublic sealed class AssetDoctorWindow : Widget\r\n{\r\n    /// <summary>Limits total scan findings retained in memory and exported.</summary>\r\n    private const int MaxScanFindings = 50_000;\r\n    /// <summary>Limits rendered occurrences under one rule group.</summary>\r\n    private const int MaxRenderedFindingsPerGroup = 250;\r\n    /// <summary>Limits rendered rule groups to protect editor responsiveness.</summary>\r\n    private const int MaxRenderedGroups = 200;\r\n    /// <summary>Bounds memory used by dependency-graph edges.</summary>\r\n    private const int MaxDependencyGraphEdges = 100_000;\r\n    /// <summary>References the scrollable findings host.</summary>\r\n    private ScrollArea? _scrollArea;\r\n    /// <summary>References the current findings canvas.</summary>\r\n    private Widget? _resultsContainer;\r\n    /// <summary>Displays scan and export status.</summary>\r\n    private Label? _statusLabel;\r\n    /// <summary>References the report export button.</summary>\r\n    private Button? _exportButton;\r\n    /// <summary>Stores the cancellation source for the active scan.</summary>\r\n    private CancellationTokenSource? _scanCts;\r\n    /// <summary>Stores findings from the most recently completed scan attempt.</summary>\r\n    private List<Finding> _lastFindings = new();\r\n    /// <summary>Invalidates stale asynchronous scan completions.</summary>\r\n    private int _scanGeneration;\r\n\r\n    /// <summary>Initializes the dock content.</summary>\r\n    public AssetDoctorWindow(Widget parent) : base(parent, false) => BuildUI();\r\n\r\n    /// <summary>Builds fresh editor controls after creation or hotload.</summary>\r\n    [EditorEvent.Hotload]\r\n    private void BuildUI()\r\n    {\r\n        CancelCurrentScan();\r\n        _scanGeneration++;\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(\"Run / Restart Scan\", \"search\", this);\r\n        scanButton.Clicked += OnScanClicked;\r\n        scanButton.MinimumSize = new Vector2(220, 38);\r\n        Layout.Add(scanButton);\r\n        _exportButton = new Button(\"Export Reports\", \"download\", this);\r\n        _exportButton.Clicked += 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 > 0) RenderFindings(_lastFindings);\r\n    }\r\n\r\n    /// <summary>Starts a new scan and cancels any earlier scan.</summary>\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 = ++_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(\"Scanning current-project source assets...\", \"#ffffff\");\r\n        try\r\n        {\r\n            var snapshot = CreateSnapshot();\r\n            var findings = await Task.Run(() => 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(\"Scan cancelled.\", \"#ffe19a\");\r\n        }\r\n        catch(Exception exception)\r\n        {\r\n            Log.Error($\"Asset Doctor scan failed: {exception}\");\r\n            if(generation == _scanGeneration) SetStatus($\"Scan failed: {exception.GetType().Name}\", \"#ffb8c0\");\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    /// <summary>Creates a UI-thread snapshot of project files and all editor-known resolvable logical paths.</summary>\r\n    private static ScanSnapshot CreateSnapshot()\r\n    {\r\n        var scope = ProjectAssetScope.TryCreate();\r\n        if(scope == null) throw new InvalidOperationException(\"No active project Assets directory is available.\");\r\n        var allEditorAssets = AssetSystem.All.Where(x => x != null && !string.IsNullOrWhiteSpace(x.Path)).ToArray();\r\n        var resolvablePaths = new HashSet<string>(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 => !string.IsNullOrWhiteSpace(x.AbsolutePath))\r\n            .Select(x => new SourceAsset(AssetPathRules.NormalizeSeparators(x.Path), x.AbsolutePath))\r\n            .ToArray();\r\n        return new ScanSnapshot(projectAssets, resolvablePaths);\r\n    }\r\n\r\n    /// <summary>Scans physical source files on a worker thread without s&box interop calls.</summary>\r\n    private static List<Finding> ScanFiles(ScanSnapshot snapshot, CancellationToken token)\r\n    {\r\n        var collector = new FindingCollector(MaxScanFindings);\r\n        var projectPaths = new Dictionary<string, string>(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(\"AD110\", FindingSeverity.Error, \"Asset paths collide when compared without letter case.\", 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<string, HashSet<string>>(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(\"AD105\", FindingSeverity.Error, \"Could not inspect text asset because its source file no longer exists.\", asset.Path)))\r\n                        return collector.Findings;\r\n                    continue;\r\n                }\r\n                if(info.Length == 0) continue;\r\n                if(info.Length > AssetPathRules.MaxTextFileBytes)\r\n                {\r\n                    if(!collector.TryAdd(new Finding(\"AD112\", FindingSeverity.Warning, \"Text asset was skipped because it exceeds the physical file-size scan limit.\", 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 > AssetPathRules.MaxTextCharacters)\r\n                {\r\n                    if(!collector.TryAdd(new Finding(\"AD112\", FindingSeverity.Warning, \"Text asset was skipped because it exceeds the decoded character scan limit.\", 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 && !collector.TryAdd(new Finding(\"AD113\", FindingSeverity.Warning, \"Reference extraction stopped after reaching the per-file reference limit.\", 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) &&\r\n                                !collector.TryAdd(new Finding(\"AD103\", FindingSeverity.Error, \"Asset reference uses different letter case than the actual asset path.\", 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<string>(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 < MaxDependencyGraphEdges)\r\n                                {\r\n                                    dependencies.Add(actualProjectPath);\r\n                                    graphEdgeCount++;\r\n                                }\r\n                                else if(!graphLimitReported)\r\n                                {\r\n                                    if(!collector.TryAdd(new Finding(\r\n                                        \"AD115\",\r\n                                        FindingSeverity.Warning,\r\n                                        \"Dependency graph limit was reached. Circular-reference results may be incomplete.\",\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(\"AD100\", FindingSeverity.Error, \"Missing asset reference.\", 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(\"AD111\", FindingSeverity.Error, $\"Could not validate asset reference ({exception.GetType().Name}).\", 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(\"AD105\", FindingSeverity.Error, $\"Could not read text asset ({exception.GetType().Name}).\", 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    /// <summary>Renders bounded groups from completed findings.</summary>\r\n    private void RenderFindings(List<Finding> 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 => x.Severity == FindingSeverity.Error);\r\n        var warnings = findings.Count(x => x.Severity == FindingSeverity.Warning);\r\n        SetStatus($\"Found {findings.Count} issues \u00b7 {errors} errors \u00b7 {warnings} warnings \u00b7 heuristic quoted-path scan\", errors > 0 ? \"#ffb8c0\" : \"#9ee6a5\");\r\n        var groups = findings.GroupBy(x => new { x.RuleId, x.Severity, x.Message }).OrderByDescending(x => Rank(x.Key.Severity)).ThenBy(x => 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 > 0 && groups.Length == MaxRenderedGroups) _resultsContainer.Layout.Add(new Label(\"More rule groups were omitted to protect Editor performance. Export reports for the full list.\", _resultsContainer));\r\n        _resultsContainer.Layout.AddStretchCell();\r\n    }\r\n\r\n    /// <summary>Adds one collapsible rule group to the results canvas.</summary>\r\n    private void AddGroup(string ruleId, FindingSeverity severity, string message, IEnumerable<Finding> findings)\r\n    {\r\n        if(_resultsContainer?.Layout == null) return;\r\n        var all = findings.OrderBy(x => x.SourcePath, StringComparer.OrdinalIgnoreCase).ThenBy(x => 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 ? \"#4a1f25\" : \"#4a3a12\";\r\n        var border = isError ? \"#d94b58\" : \"#e0ae32\";\r\n        var text = isError ? \"#ffb8c0\" : \"#ffe19a\";\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 = $\"\u25b6 {ruleId} \u00b7 {message} \u00b7 {all.Length} issue(s)\";\r\n        var open = $\"\u25bc {ruleId} \u00b7 {message} \u00b7 {all.Length} issue(s)\";\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 += () =>\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 : $\" \u00b7 {finding.Details}\";\r\n            var line = finding.Line.HasValue ? $\":{finding.Line.Value}\" : string.Empty;\r\n            var row = new FindingRow(holder, $\"{finding.RuleId} \u00b7 {finding.Message} \u00b7 {finding.SourcePath}{line} \u2192 {finding.ReferencedPath}{detail}\", background, border, text);\r\n            row.Clicked += () => AssetBrowserNavigator.FocusAsset(source);\r\n            holder.Layout.Add(row, 1);\r\n            details.Layout.Add(holder);\r\n        }\r\n        if(all.Length > rendered.Length) details.Layout.Add(new Label($\"\u2026 {all.Length - rendered.Length} more issue(s); export reports for the full list.\", details));\r\n        container.Layout.Add(details);\r\n    }\r\n\r\n    /// <summary>Exports reports through a user-selected directory.</summary>\r\n    private void ExportReports()\r\n    {\r\n        if(_lastFindings.Count == 0) return;\r\n        var dialog = new FileDialog(this) { Title = \"Select Report Folder\" };\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)) ?? \"project\";\r\n            AssetDoctorReportExporter.Export(dialog.Directory, name, _lastFindings);\r\n            SetStatus($\"Reports exported to {dialog.Directory}\", \"#9ee6a5\");\r\n        }\r\n        catch(Exception exception)\r\n        {\r\n            Log.Error($\"Asset Doctor export failed: {exception}\");\r\n            SetStatus($\"Export failed: {exception.GetType().Name}\", \"#ffb8c0\");\r\n        }\r\n    }\r\n\r\n    /// <summary>Creates the scroll canvas used for results.</summary>\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    /// <summary>Cancels and detaches the active scan; its owner disposes the source after completion.</summary>\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    /// <summary>Updates status text and its severity color.</summary>\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($\"padding: 4px 2px; color: {color};\");\r\n    }\r\n\r\n    /// <summary>Returns sort precedence for severities.</summary>\r\n    private static int Rank(FindingSeverity severity) => severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;\r\n    /// <summary>Bounds retained findings and adds one explicit truncation diagnostic when the scan limit is reached.</summary>\r\n    private sealed class FindingCollector\r\n    {\r\n        /// <summary>Stores retained findings.</summary>\r\n        private readonly List<Finding> _findings = new();\r\n        /// <summary>Stores the maximum retained finding count.</summary>\r\n        private readonly int _limit;\r\n        /// <summary>Tracks whether the terminal limit finding was added.</summary>\r\n        private bool _truncated;\r\n\r\n        /// <summary>Initializes a bounded finding collector.</summary>\r\n        public FindingCollector(int limit) => _limit = limit;\r\n        /// <summary>Gets retained findings.</summary>\r\n        public List<Finding> Findings => _findings;\r\n        /// <summary>Adds a finding or a final truncation diagnostic, returning false when scanning must stop.</summary>\r\n        public bool TryAdd(Finding finding)\r\n        {\r\n            if(_truncated) return false;\r\n            if(_findings.Count < _limit - 1)\r\n            {\r\n                _findings.Add(finding);\r\n                return true;\r\n            }\r\n\r\n            _findings.Add(new Finding(\"AD114\", FindingSeverity.Warning, \"Scan stopped after reaching the global finding limit.\", finding.SourcePath, finding.ReferencedPath, finding.Line));\r\n            _truncated = true;\r\n            return false;\r\n        }\r\n    }\r\n\r\n    /// <summary>Represents a physical source file captured on the UI thread.</summary>\r\n    private sealed record SourceAsset(string Path, string AbsolutePath);\r\n    /// <summary>Represents a scan-input snapshot that worker code treats as read-only.</summary>\r\n    private sealed record ScanSnapshot(SourceAsset[] Assets, HashSet<string> 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/// <summary>Provides a styled clickable row without native button rendering.</summary>\r\npublic sealed class FindingRow : Widget\r\n{\r\n    /// <summary>Displays the row text.</summary>\r\n    private readonly Label _label;\r\n    /// <summary>Raised when the row receives a mouse click.</summary>\r\n    public event Action? Clicked;\r\n    /// <summary>Gets or sets the visible row text.</summary>\r\n    public string Text { get => _label.Text; set => _label.Text = value; }\r\n    /// <summary>Creates a styled row using caller-supplied colors.</summary>\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($\"background-color: {background}; border: 1px solid {border};\");\r\n        _label = new Label(text, this) { TransparentForMouseEvents = true };\r\n        _label.SetStyles($\"color: {color}; padding: 0px 6px; background-color: transparent;\"); Layout.Add(_label);\r\n        MouseClick += OnMouseClick;\r\n    }\r\n    /// <summary>Raises the row click event.</summary>\r\n    private void OnMouseClick() => Clicked?.Invoke();\r\n}\r\n"
        }
    ]
}