save development progress
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomParser.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-005
|
||||
// Description: Interface for SBOM parsing and PURL extraction
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Service for parsing SBOM content and extracting package identifiers.
|
||||
/// </summary>
|
||||
public interface ISbomParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts PURLs from SBOM content.
|
||||
/// </summary>
|
||||
/// <param name="content">SBOM content stream.</param>
|
||||
/// <param name="format">SBOM format (CycloneDX or SPDX).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Parsing result with extracted PURLs.</returns>
|
||||
Task<SbomParseResult> ParseAsync(
|
||||
Stream content,
|
||||
SbomFormat format,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detects the SBOM format from content.
|
||||
/// </summary>
|
||||
/// <param name="content">SBOM content stream.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Detected format and spec version.</returns>
|
||||
Task<SbomFormatInfo> DetectFormatAsync(
|
||||
Stream content,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of SBOM parsing.
|
||||
/// </summary>
|
||||
public sealed record SbomParseResult
|
||||
{
|
||||
/// <summary>List of extracted PURLs.</summary>
|
||||
public required IReadOnlyList<string> Purls { get; init; }
|
||||
|
||||
/// <summary>List of extracted CPEs (for OS packages).</summary>
|
||||
public IReadOnlyList<string> Cpes { get; init; } = [];
|
||||
|
||||
/// <summary>Primary component name (e.g., image name).</summary>
|
||||
public string? PrimaryName { get; init; }
|
||||
|
||||
/// <summary>Primary component version.</summary>
|
||||
public string? PrimaryVersion { get; init; }
|
||||
|
||||
/// <summary>Total component count in SBOM.</summary>
|
||||
public int TotalComponents { get; init; }
|
||||
|
||||
/// <summary>Components without PURL (name/version only).</summary>
|
||||
public IReadOnlyList<ComponentInfo> UnresolvedComponents { get; init; } = [];
|
||||
|
||||
/// <summary>Parsing warnings (non-fatal issues).</summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a component without PURL.
|
||||
/// </summary>
|
||||
public sealed record ComponentInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detected SBOM format information.
|
||||
/// </summary>
|
||||
public sealed record SbomFormatInfo
|
||||
{
|
||||
/// <summary>SBOM format.</summary>
|
||||
public SbomFormat Format { get; init; }
|
||||
|
||||
/// <summary>Specification version (e.g., "1.5" for CycloneDX).</summary>
|
||||
public string? SpecVersion { get; init; }
|
||||
|
||||
/// <summary>Whether format was successfully detected.</summary>
|
||||
public bool IsDetected { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomParser.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-005
|
||||
// Description: SBOM parser for CycloneDX and SPDX formats
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for extracting PURLs and metadata from SBOM documents.
|
||||
/// Supports CycloneDX (1.4-1.6) and SPDX (2.2-2.3, 3.0).
|
||||
/// </summary>
|
||||
public sealed class SbomParser : ISbomParser
|
||||
{
|
||||
private readonly ILogger<SbomParser> _logger;
|
||||
|
||||
public SbomParser(ILogger<SbomParser> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomParseResult> ParseAsync(
|
||||
Stream content,
|
||||
SbomFormat format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Ensure stream is at beginning
|
||||
if (content.CanSeek)
|
||||
{
|
||||
content.Position = 0;
|
||||
}
|
||||
|
||||
return format switch
|
||||
{
|
||||
SbomFormat.CycloneDX => await ParseCycloneDxAsync(content, cancellationToken).ConfigureAwait(false),
|
||||
SbomFormat.SPDX => await ParseSpdxAsync(content, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new ArgumentException($"Unsupported SBOM format: {format}", nameof(format))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomFormatInfo> DetectFormatAsync(
|
||||
Stream content,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
if (content.CanSeek)
|
||||
{
|
||||
content.Position = 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check for CycloneDX
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
|
||||
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var specVersion = root.TryGetProperty("specVersion", out var sv) ? sv.GetString() : null;
|
||||
return new SbomFormatInfo
|
||||
{
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = specVersion,
|
||||
IsDetected = true
|
||||
};
|
||||
}
|
||||
|
||||
// Check for SPDX 2.x
|
||||
if (root.TryGetProperty("spdxVersion", out var spdxVersion))
|
||||
{
|
||||
return new SbomFormatInfo
|
||||
{
|
||||
Format = SbomFormat.SPDX,
|
||||
SpecVersion = spdxVersion.GetString(),
|
||||
IsDetected = true
|
||||
};
|
||||
}
|
||||
|
||||
// Check for SPDX 3.0 (@context indicates JSON-LD)
|
||||
if (root.TryGetProperty("@context", out var context))
|
||||
{
|
||||
var contextStr = context.ValueKind == JsonValueKind.String
|
||||
? context.GetString()
|
||||
: context.ToString();
|
||||
|
||||
if (contextStr?.Contains("spdx", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return new SbomFormatInfo
|
||||
{
|
||||
Format = SbomFormat.SPDX,
|
||||
SpecVersion = "3.0",
|
||||
IsDetected = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new SbomFormatInfo { IsDetected = false };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse SBOM content as JSON");
|
||||
return new SbomFormatInfo { IsDetected = false };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SbomParseResult> ParseCycloneDxAsync(
|
||||
Stream content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
var purls = new List<string>();
|
||||
var cpes = new List<string>();
|
||||
var unresolvedComponents = new List<ComponentInfo>();
|
||||
var warnings = new List<string>();
|
||||
string? primaryName = null;
|
||||
string? primaryVersion = null;
|
||||
int totalComponents = 0;
|
||||
|
||||
// Get primary component from metadata
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("component", out var primaryComponent))
|
||||
{
|
||||
primaryName = primaryComponent.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
primaryVersion = primaryComponent.TryGetProperty("version", out var version) ? version.GetString() : null;
|
||||
|
||||
// Primary component may also have a PURL
|
||||
if (primaryComponent.TryGetProperty("purl", out var primaryPurl))
|
||||
{
|
||||
var purlStr = primaryPurl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(purlStr))
|
||||
{
|
||||
purls.Add(purlStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse components array
|
||||
if (root.TryGetProperty("components", out var components))
|
||||
{
|
||||
foreach (var component in components.EnumerateArray())
|
||||
{
|
||||
totalComponents++;
|
||||
ParseCycloneDxComponent(component, purls, cpes, unresolvedComponents, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse nested components (CycloneDX supports component hierarchy)
|
||||
ParseNestedComponents(root, purls, cpes, unresolvedComponents, warnings, ref totalComponents);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Parsed CycloneDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} components",
|
||||
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
|
||||
|
||||
return new SbomParseResult
|
||||
{
|
||||
Purls = purls.Distinct().ToList(),
|
||||
Cpes = cpes.Distinct().ToList(),
|
||||
PrimaryName = primaryName,
|
||||
PrimaryVersion = primaryVersion,
|
||||
TotalComponents = totalComponents,
|
||||
UnresolvedComponents = unresolvedComponents,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private void ParseCycloneDxComponent(
|
||||
JsonElement component,
|
||||
List<string> purls,
|
||||
List<string> cpes,
|
||||
List<ComponentInfo> unresolved,
|
||||
List<string> warnings)
|
||||
{
|
||||
var hasPurl = false;
|
||||
|
||||
// Extract PURL
|
||||
if (component.TryGetProperty("purl", out var purl))
|
||||
{
|
||||
var purlStr = purl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(purlStr))
|
||||
{
|
||||
purls.Add(purlStr);
|
||||
hasPurl = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CPE (from cpe property or externalReferences)
|
||||
if (component.TryGetProperty("cpe", out var cpe))
|
||||
{
|
||||
var cpeStr = cpe.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(cpeStr))
|
||||
{
|
||||
cpes.Add(cpeStr);
|
||||
}
|
||||
}
|
||||
|
||||
// Check externalReferences for additional CPEs
|
||||
if (component.TryGetProperty("externalReferences", out var extRefs))
|
||||
{
|
||||
foreach (var extRef in extRefs.EnumerateArray())
|
||||
{
|
||||
if (extRef.TryGetProperty("type", out var type) &&
|
||||
type.GetString()?.Equals("cpe", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
extRef.TryGetProperty("url", out var url))
|
||||
{
|
||||
var cpeStr = url.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(cpeStr))
|
||||
{
|
||||
cpes.Add(cpeStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track unresolved components (no PURL)
|
||||
if (!hasPurl)
|
||||
{
|
||||
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
var componentType = component.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
unresolved.Add(new ComponentInfo
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Type = componentType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively parse nested components
|
||||
if (component.TryGetProperty("components", out var nestedComponents))
|
||||
{
|
||||
foreach (var nested in nestedComponents.EnumerateArray())
|
||||
{
|
||||
ParseCycloneDxComponent(nested, purls, cpes, unresolved, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseNestedComponents(
|
||||
JsonElement root,
|
||||
List<string> purls,
|
||||
List<string> cpes,
|
||||
List<ComponentInfo> unresolved,
|
||||
List<string> warnings,
|
||||
ref int totalComponents)
|
||||
{
|
||||
// CycloneDX 1.5+ supports dependencies with nested refs
|
||||
if (root.TryGetProperty("dependencies", out var dependencies))
|
||||
{
|
||||
// Dependencies section doesn't contain component data, just refs
|
||||
// Already handled through components traversal
|
||||
}
|
||||
|
||||
// Check for compositions (CycloneDX 1.4+)
|
||||
if (root.TryGetProperty("compositions", out var compositions))
|
||||
{
|
||||
// Compositions define relationships but don't add new components
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SbomParseResult> ParseSpdxAsync(
|
||||
Stream content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
var purls = new List<string>();
|
||||
var cpes = new List<string>();
|
||||
var unresolvedComponents = new List<ComponentInfo>();
|
||||
var warnings = new List<string>();
|
||||
string? primaryName = null;
|
||||
string? primaryVersion = null;
|
||||
int totalComponents = 0;
|
||||
|
||||
// Detect SPDX version
|
||||
var isSpdx3 = root.TryGetProperty("@context", out _);
|
||||
|
||||
if (isSpdx3)
|
||||
{
|
||||
return await ParseSpdx3Async(root, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// SPDX 2.x parsing
|
||||
// Get document name as primary
|
||||
if (root.TryGetProperty("name", out var docName))
|
||||
{
|
||||
primaryName = docName.GetString();
|
||||
}
|
||||
|
||||
// Parse packages
|
||||
if (root.TryGetProperty("packages", out var packages))
|
||||
{
|
||||
foreach (var package in packages.EnumerateArray())
|
||||
{
|
||||
totalComponents++;
|
||||
ParseSpdxPackage(package, purls, cpes, unresolvedComponents, warnings);
|
||||
|
||||
// First package is often the primary
|
||||
if (primaryName is null && package.TryGetProperty("name", out var pkgName))
|
||||
{
|
||||
primaryName = pkgName.GetString();
|
||||
primaryVersion = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Parsed SPDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} packages",
|
||||
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
|
||||
|
||||
return new SbomParseResult
|
||||
{
|
||||
Purls = purls.Distinct().ToList(),
|
||||
Cpes = cpes.Distinct().ToList(),
|
||||
PrimaryName = primaryName,
|
||||
PrimaryVersion = primaryVersion,
|
||||
TotalComponents = totalComponents,
|
||||
UnresolvedComponents = unresolvedComponents,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private void ParseSpdxPackage(
|
||||
JsonElement package,
|
||||
List<string> purls,
|
||||
List<string> cpes,
|
||||
List<ComponentInfo> unresolved,
|
||||
List<string> warnings)
|
||||
{
|
||||
var hasPurl = false;
|
||||
|
||||
// Extract from externalRefs
|
||||
if (package.TryGetProperty("externalRefs", out var extRefs))
|
||||
{
|
||||
foreach (var extRef in extRefs.EnumerateArray())
|
||||
{
|
||||
var refType = extRef.TryGetProperty("referenceType", out var rt) ? rt.GetString() : null;
|
||||
var refCategory = extRef.TryGetProperty("referenceCategory", out var rc) ? rc.GetString() : null;
|
||||
var locator = extRef.TryGetProperty("referenceLocator", out var loc) ? loc.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(locator))
|
||||
continue;
|
||||
|
||||
// PURL reference
|
||||
if (refType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
refCategory?.Equals("PACKAGE-MANAGER", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (locator.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
purls.Add(locator);
|
||||
hasPurl = true;
|
||||
}
|
||||
}
|
||||
|
||||
// CPE reference
|
||||
if (refType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
refCategory?.Equals("SECURITY", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (locator.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cpes.Add(locator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track unresolved packages (no PURL)
|
||||
if (!hasPurl)
|
||||
{
|
||||
var name = package.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
unresolved.Add(new ComponentInfo
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Type = "package"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task<SbomParseResult> ParseSpdx3Async(
|
||||
JsonElement root,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var purls = new List<string>();
|
||||
var cpes = new List<string>();
|
||||
var unresolvedComponents = new List<ComponentInfo>();
|
||||
var warnings = new List<string>();
|
||||
string? primaryName = null;
|
||||
string? primaryVersion = null;
|
||||
int totalComponents = 0;
|
||||
|
||||
// SPDX 3.0 uses "@graph" for elements
|
||||
if (root.TryGetProperty("@graph", out var graph))
|
||||
{
|
||||
foreach (var element in graph.EnumerateArray())
|
||||
{
|
||||
var elementType = element.TryGetProperty("@type", out var t) ? t.GetString() : null;
|
||||
|
||||
// Skip non-package elements
|
||||
if (elementType is null ||
|
||||
(!elementType.Contains("Package", StringComparison.OrdinalIgnoreCase) &&
|
||||
!elementType.Contains("Software", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalComponents++;
|
||||
var hasPurl = false;
|
||||
|
||||
// SPDX 3.0 uses packageUrl property directly
|
||||
if (element.TryGetProperty("packageUrl", out var purl))
|
||||
{
|
||||
var purlStr = purl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(purlStr))
|
||||
{
|
||||
purls.Add(purlStr);
|
||||
hasPurl = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check externalIdentifier array
|
||||
if (element.TryGetProperty("externalIdentifier", out var extIds))
|
||||
{
|
||||
foreach (var extId in extIds.EnumerateArray())
|
||||
{
|
||||
var idType = extId.TryGetProperty("externalIdentifierType", out var eit)
|
||||
? eit.GetString()
|
||||
: null;
|
||||
var idValue = extId.TryGetProperty("identifier", out var id) ? id.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(idValue))
|
||||
continue;
|
||||
|
||||
if (idType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
purls.Add(idValue);
|
||||
hasPurl = true;
|
||||
}
|
||||
else if (idType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
cpes.Add(idValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track unresolved
|
||||
if (!hasPurl)
|
||||
{
|
||||
var name = element.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = element.TryGetProperty("packageVersion", out var v)
|
||||
? v.GetString()
|
||||
: element.TryGetProperty("softwareVersion", out var sv)
|
||||
? sv.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
unresolvedComponents.Add(new ComponentInfo
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Type = elementType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get primary from first package
|
||||
if (primaryName is null)
|
||||
{
|
||||
primaryName = element.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
primaryVersion = element.TryGetProperty("packageVersion", out var v) ? v.GetString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Parsed SPDX 3.0 SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} elements",
|
||||
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
|
||||
|
||||
return Task.FromResult(new SbomParseResult
|
||||
{
|
||||
Purls = purls.Distinct().ToList(),
|
||||
Cpes = cpes.Distinct().ToList(),
|
||||
PrimaryName = primaryName,
|
||||
PrimaryVersion = primaryVersion,
|
||||
TotalComponents = totalComponents,
|
||||
UnresolvedComponents = unresolvedComponents,
|
||||
Warnings = warnings
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user