🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=mikekotys.assetdoctor&take=20
Showing code results for query:
*
(12 total matches found)
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);
}
}
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("<", "<").Replace(">", ">");
/// <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;
}
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");
};
}
}
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;
}
}
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")]
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;
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);
}
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();
}
}
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));
}
}
}
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;
}
}
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);
}
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(\"<\", \"<\").Replace(\">\", \">\");\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"
}
]
}