Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added RuntimeFactsNdjsonReader for reading NDJSON formatted runtime facts. - Introduced IRuntimeFactsIngestionService interface and its implementation. - Enhanced Program.cs to register new services and endpoints for runtime facts. - Updated CallgraphIngestionService to include CAS URI in stored artifacts. - Created RuntimeFactsValidationException for validation errors during ingestion. - Added tests for RuntimeFactsIngestionService and RuntimeFactsNdjsonReader. - Implemented SignalsSealedModeMonitor for compliance checks in sealed mode. - Updated project dependencies for testing utilities.
710 lines
27 KiB
C#
710 lines
27 KiB
C#
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
|
|
|
internal static class DenoModuleGraphResolver
|
|
{
|
|
public static DenoModuleGraph Resolve(DenoWorkspace workspace, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(workspace);
|
|
var builder = new GraphBuilder(cancellationToken);
|
|
builder.AddWorkspace(workspace);
|
|
return builder.Build();
|
|
}
|
|
|
|
private sealed class GraphBuilder
|
|
{
|
|
private readonly CancellationToken _cancellationToken;
|
|
private readonly Dictionary<string, DenoModuleNode> _nodes = new(StringComparer.Ordinal);
|
|
private readonly List<DenoModuleEdge> _edges = new();
|
|
|
|
public GraphBuilder(CancellationToken cancellationToken)
|
|
{
|
|
_cancellationToken = cancellationToken;
|
|
}
|
|
|
|
public DenoModuleGraph Build()
|
|
=> new(_nodes.Values, _edges);
|
|
|
|
public void AddWorkspace(DenoWorkspace workspace)
|
|
{
|
|
AddConfigurations(workspace);
|
|
AddImportMaps(workspace);
|
|
AddLockFiles(workspace);
|
|
AddVendors(workspace);
|
|
AddCacheLocations(workspace);
|
|
}
|
|
|
|
private void AddConfigurations(DenoWorkspace workspace)
|
|
{
|
|
foreach (var config in workspace.Configurations)
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
var configNodeId = GetOrAddNode(
|
|
$"config::{config.RelativePath}",
|
|
config.RelativePath,
|
|
DenoModuleKind.WorkspaceConfig,
|
|
config.AbsolutePath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: new Dictionary<string, string?>()
|
|
{
|
|
["vendor.enabled"] = config.VendorEnabled.ToString(CultureInfo.InvariantCulture),
|
|
["lock.enabled"] = config.LockEnabled.ToString(CultureInfo.InvariantCulture),
|
|
["nodeModules.enabled"] = config.NodeModulesDirEnabled.ToString(CultureInfo.InvariantCulture),
|
|
});
|
|
|
|
if (config.ImportMapPath is not null)
|
|
{
|
|
var importMapNodeId = GetOrAddNode(
|
|
$"import-map::{NormalizePath(config.ImportMapPath)}",
|
|
config.ImportMapPath,
|
|
DenoModuleKind.ImportMap,
|
|
config.ImportMapPath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
configNodeId,
|
|
importMapNodeId,
|
|
DenoImportKind.Static,
|
|
specifier: "importMap",
|
|
provenance: $"config:{config.RelativePath}",
|
|
resolution: config.ImportMapPath);
|
|
}
|
|
|
|
if (config.InlineImportMap is not null)
|
|
{
|
|
var inlineNodeId = GetOrAddNode(
|
|
$"import-map::{config.RelativePath}#inline",
|
|
$"{config.RelativePath} (inline import map)",
|
|
DenoModuleKind.ImportMap,
|
|
config.RelativePath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
configNodeId,
|
|
inlineNodeId,
|
|
DenoImportKind.Static,
|
|
specifier: "importMap:inline",
|
|
provenance: $"config:{config.RelativePath}",
|
|
resolution: config.RelativePath);
|
|
}
|
|
|
|
if (config.LockFilePath is not null)
|
|
{
|
|
var lockNodeId = GetOrAddNode(
|
|
$"lock::{NormalizePath(config.LockFilePath)}",
|
|
config.LockFilePath,
|
|
DenoModuleKind.LockFile,
|
|
config.LockFilePath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
configNodeId,
|
|
lockNodeId,
|
|
DenoImportKind.Static,
|
|
specifier: "lock",
|
|
provenance: $"config:{config.RelativePath}",
|
|
resolution: config.LockFilePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void AddImportMaps(DenoWorkspace workspace)
|
|
{
|
|
foreach (var importMap in workspace.ImportMaps)
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var importMapNodeId = GetOrAddNode(
|
|
$"import-map::{importMap.SortKey}",
|
|
importMap.Origin,
|
|
DenoModuleKind.ImportMap,
|
|
importMap.AbsolutePath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
foreach (var entry in importMap.Imports)
|
|
{
|
|
var aliasNodeId = GetOrAddAliasNode(entry.Key, importMapNodeId);
|
|
var targetNodeId = GetOrAddTargetNode(entry.Value);
|
|
AddEdge(
|
|
aliasNodeId,
|
|
targetNodeId,
|
|
DetermineImportKind(entry.Value),
|
|
entry.Key,
|
|
provenance: $"import-map:{importMap.SortKey}",
|
|
resolution: entry.Value);
|
|
}
|
|
|
|
foreach (var scope in importMap.Scopes)
|
|
{
|
|
foreach (var scopedEntry in scope.Value)
|
|
{
|
|
var aliasNodeId = GetOrAddAliasNode($"{scope.Key}:{scopedEntry.Key}", importMapNodeId);
|
|
var targetNodeId = GetOrAddTargetNode(scopedEntry.Value);
|
|
AddEdge(
|
|
aliasNodeId,
|
|
targetNodeId,
|
|
DetermineImportKind(scopedEntry.Value),
|
|
scopedEntry.Key,
|
|
provenance: $"import-map-scope:{scope.Key}",
|
|
resolution: scopedEntry.Value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void AddLockFiles(DenoWorkspace workspace)
|
|
{
|
|
foreach (var lockFile in workspace.LockFiles)
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var lockNodeId = GetOrAddNode(
|
|
$"lock::{lockFile.RelativePath}",
|
|
lockFile.RelativePath,
|
|
DenoModuleKind.LockFile,
|
|
lockFile.AbsolutePath,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: new Dictionary<string, string?>()
|
|
{
|
|
["lock.version"] = lockFile.Version,
|
|
});
|
|
|
|
foreach (var remote in lockFile.RemoteEntries)
|
|
{
|
|
var aliasNodeId = GetOrAddAliasNode(remote.Key, lockNodeId);
|
|
var remoteNodeId = GetOrAddNode(
|
|
$"remote::{remote.Key}",
|
|
remote.Key,
|
|
DenoModuleKind.RemoteModule,
|
|
remote.Key,
|
|
layerDigest: null,
|
|
integrity: remote.Value,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
aliasNodeId,
|
|
remoteNodeId,
|
|
DetermineImportKind(remote.Key),
|
|
remote.Key,
|
|
provenance: $"lock:remote:{lockFile.RelativePath}",
|
|
resolution: remote.Key);
|
|
}
|
|
|
|
foreach (var redirect in lockFile.Redirects)
|
|
{
|
|
var fromNodeId = GetOrAddAliasNode(redirect.Key, lockNodeId);
|
|
var toNodeId = GetOrAddAliasNode(redirect.Value, lockNodeId);
|
|
AddEdge(
|
|
fromNodeId,
|
|
toNodeId,
|
|
DenoImportKind.Redirect,
|
|
redirect.Key,
|
|
provenance: $"lock:redirect:{lockFile.RelativePath}",
|
|
resolution: redirect.Value);
|
|
}
|
|
|
|
foreach (var npmSpecifier in lockFile.NpmSpecifiers)
|
|
{
|
|
var aliasNodeId = GetOrAddNode(
|
|
$"npm-spec::{npmSpecifier.Key}",
|
|
npmSpecifier.Key,
|
|
DenoModuleKind.NpmSpecifier,
|
|
npmSpecifier.Key,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
var packageNodeId = GetOrAddNode(
|
|
$"npm::{npmSpecifier.Value}",
|
|
npmSpecifier.Value,
|
|
DenoModuleKind.NpmPackage,
|
|
npmSpecifier.Value,
|
|
layerDigest: null,
|
|
integrity: lockFile.NpmPackages.TryGetValue(npmSpecifier.Value, out var pkg) ? pkg.Integrity : null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
aliasNodeId,
|
|
packageNodeId,
|
|
DenoImportKind.NpmBridge,
|
|
npmSpecifier.Key,
|
|
provenance: $"lock:npm-spec:{lockFile.RelativePath}",
|
|
resolution: npmSpecifier.Value);
|
|
}
|
|
|
|
foreach (var package in lockFile.NpmPackages)
|
|
{
|
|
var packageNodeId = GetOrAddNode(
|
|
$"npm::{package.Key}",
|
|
package.Key,
|
|
DenoModuleKind.NpmPackage,
|
|
package.Key,
|
|
layerDigest: null,
|
|
integrity: package.Value.Integrity,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
foreach (var dependency in package.Value.Dependencies)
|
|
{
|
|
var dependencyNodeId = GetOrAddNode(
|
|
$"npm::{dependency.Value}",
|
|
dependency.Value,
|
|
DenoModuleKind.NpmPackage,
|
|
dependency.Value,
|
|
layerDigest: null,
|
|
integrity: lockFile.NpmPackages.TryGetValue(dependency.Value, out var depPkg) ? depPkg.Integrity : null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
packageNodeId,
|
|
dependencyNodeId,
|
|
DenoImportKind.Dependency,
|
|
dependency.Key,
|
|
provenance: $"lock:npm-package:{lockFile.RelativePath}",
|
|
resolution: dependency.Value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void AddVendors(DenoWorkspace workspace)
|
|
{
|
|
foreach (var vendor in workspace.Vendors)
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["vendor.alias"] = vendor.Alias,
|
|
};
|
|
|
|
if (vendor.ImportMap is not null)
|
|
{
|
|
var vendorMapId = GetOrAddNode(
|
|
$"vendor-map::{vendor.Alias}",
|
|
$"{vendor.Alias} import map",
|
|
DenoModuleKind.ImportMap,
|
|
vendor.ImportMap.AbsolutePath,
|
|
vendor.LayerDigest,
|
|
integrity: null,
|
|
metadata);
|
|
|
|
var vendorRootId = GetOrAddNode(
|
|
$"vendor-root::{vendor.Alias}",
|
|
vendor.RelativePath,
|
|
DenoModuleKind.VendorModule,
|
|
vendor.AbsolutePath,
|
|
vendor.LayerDigest,
|
|
integrity: null,
|
|
metadata);
|
|
|
|
AddEdge(
|
|
vendorRootId,
|
|
vendorMapId,
|
|
DenoImportKind.Cache,
|
|
vendor.ImportMap.Origin,
|
|
provenance: $"vendor:{vendor.Alias}",
|
|
resolution: vendor.ImportMap.AbsolutePath);
|
|
}
|
|
|
|
foreach (var file in SafeEnumerateFiles(vendor.AbsolutePath))
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
var relative = Path.GetRelativePath(vendor.AbsolutePath, file);
|
|
var nodeId = GetOrAddNode(
|
|
$"vendor::{vendor.Alias}/{NormalizePath(relative)}",
|
|
$"{vendor.Alias}/{NormalizePath(relative)}",
|
|
DenoModuleKind.VendorModule,
|
|
file,
|
|
vendor.LayerDigest,
|
|
integrity: null,
|
|
metadata);
|
|
|
|
if (TryResolveUrlFromVendorPath(vendor.RelativePath, relative, out var url) ||
|
|
TryResolveUrlFromVendorPath(vendor.RelativePath, $"https/{relative}", out url))
|
|
{
|
|
var remoteNodeId = GetOrAddNode(
|
|
$"remote::{url}",
|
|
url,
|
|
DenoModuleKind.RemoteModule,
|
|
url,
|
|
vendor.LayerDigest,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
remoteNodeId,
|
|
nodeId,
|
|
DenoImportKind.Cache,
|
|
url,
|
|
provenance: $"vendor-cache:{vendor.Alias}",
|
|
resolution: file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void AddCacheLocations(DenoWorkspace workspace)
|
|
{
|
|
foreach (var cache in workspace.CacheLocations)
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
foreach (var file in SafeEnumerateFiles(cache.AbsolutePath))
|
|
{
|
|
_cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var relative = Path.GetRelativePath(cache.AbsolutePath, file);
|
|
var nodeId = GetOrAddNode(
|
|
$"cache::{cache.Alias}/{NormalizePath(relative)}",
|
|
$"{cache.Alias}/{NormalizePath(relative)}",
|
|
DenoModuleKind.CacheEntry,
|
|
file,
|
|
cache.LayerDigest,
|
|
integrity: null,
|
|
metadata: new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["cache.kind"] = cache.Kind.ToString(),
|
|
});
|
|
|
|
if (TryResolveUrlFromCachePath(relative, out var url))
|
|
{
|
|
var remoteNodeId = GetOrAddNode(
|
|
$"remote::{url}",
|
|
url,
|
|
DenoModuleKind.RemoteModule,
|
|
url,
|
|
cache.LayerDigest,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
|
|
AddEdge(
|
|
remoteNodeId,
|
|
nodeId,
|
|
DenoImportKind.Cache,
|
|
url,
|
|
provenance: $"cache:{cache.Kind}",
|
|
resolution: file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private string GetOrAddAliasNode(string specifier, string ownerNodeId)
|
|
{
|
|
var normalized = NormalizeSpecifier(specifier);
|
|
if (_nodes.ContainsKey(normalized))
|
|
{
|
|
return normalized;
|
|
}
|
|
|
|
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["owner"] = ownerNodeId,
|
|
};
|
|
|
|
var kind = specifier.StartsWith("node:", StringComparison.OrdinalIgnoreCase) ||
|
|
specifier.StartsWith("deno:", StringComparison.OrdinalIgnoreCase)
|
|
? DenoModuleKind.BuiltInModule
|
|
: DenoModuleKind.SpecifierAlias;
|
|
|
|
return GetOrAddNode(
|
|
normalized,
|
|
specifier,
|
|
kind,
|
|
specifier,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata);
|
|
}
|
|
|
|
private string GetOrAddTargetNode(string target)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
{
|
|
return GetOrAddNode(
|
|
"unknown::(empty)",
|
|
"(empty)",
|
|
DenoModuleKind.Unknown,
|
|
reference: null,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
if (target.StartsWith("npm:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return GetOrAddNode(
|
|
$"npm::{target[4..]}",
|
|
target,
|
|
DenoModuleKind.NpmPackage,
|
|
target,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
if (target.StartsWith("node:", StringComparison.OrdinalIgnoreCase) ||
|
|
target.StartsWith("deno:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return GetOrAddNode(
|
|
$"builtin::{target}",
|
|
target,
|
|
DenoModuleKind.BuiltInModule,
|
|
target,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return GetOrAddNode(
|
|
$"remote::{target}",
|
|
target,
|
|
DenoModuleKind.RemoteModule,
|
|
target,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
if (target.StartsWith("./", StringComparison.Ordinal) || target.StartsWith("../", StringComparison.Ordinal) || target.StartsWith("/", StringComparison.Ordinal))
|
|
{
|
|
return GetOrAddNode(
|
|
$"workspace::{NormalizePath(target)}",
|
|
target,
|
|
DenoModuleKind.WorkspaceModule,
|
|
target,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
return GetOrAddNode(
|
|
$"alias::{NormalizeSpecifier(target)}",
|
|
target,
|
|
DenoModuleKind.SpecifierAlias,
|
|
target,
|
|
layerDigest: null,
|
|
integrity: null,
|
|
metadata: ImmutableDictionary<string, string?>.Empty);
|
|
}
|
|
|
|
private string GetOrAddNode(
|
|
string id,
|
|
string? displayName,
|
|
DenoModuleKind kind,
|
|
string? reference,
|
|
string? layerDigest,
|
|
string? integrity,
|
|
IReadOnlyDictionary<string, string?> metadata)
|
|
{
|
|
displayName ??= "(unknown)";
|
|
metadata ??= ImmutableDictionary<string, string?>.Empty;
|
|
|
|
if (_nodes.TryGetValue(id, out var existing))
|
|
{
|
|
var updated = existing;
|
|
|
|
if (string.IsNullOrEmpty(existing.Reference) && !string.IsNullOrEmpty(reference))
|
|
{
|
|
updated = updated with { Reference = reference };
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(existing.LayerDigest) && !string.IsNullOrEmpty(layerDigest))
|
|
{
|
|
updated = updated with { LayerDigest = layerDigest };
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(existing.Integrity) && !string.IsNullOrEmpty(integrity))
|
|
{
|
|
updated = updated with { Integrity = integrity };
|
|
}
|
|
|
|
if (metadata.Count > 0)
|
|
{
|
|
var combined = new Dictionary<string, string?>(existing.Metadata, StringComparer.Ordinal);
|
|
foreach (var pair in metadata)
|
|
{
|
|
combined[pair.Key] = pair.Value;
|
|
}
|
|
|
|
updated = updated with { Metadata = combined };
|
|
}
|
|
|
|
if (!ReferenceEquals(updated, existing))
|
|
{
|
|
_nodes[id] = updated;
|
|
}
|
|
|
|
return updated.Id;
|
|
}
|
|
|
|
_nodes[id] = new DenoModuleNode(
|
|
id,
|
|
displayName,
|
|
kind,
|
|
reference,
|
|
layerDigest,
|
|
integrity,
|
|
metadata);
|
|
|
|
return id;
|
|
}
|
|
|
|
private void AddEdge(
|
|
string from,
|
|
string to,
|
|
DenoImportKind kind,
|
|
string? specifier,
|
|
string provenance,
|
|
string? resolution)
|
|
{
|
|
specifier ??= "(unknown)";
|
|
if (string.Equals(from, to, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_edges.Add(new DenoModuleEdge(
|
|
from,
|
|
to,
|
|
kind,
|
|
specifier,
|
|
provenance,
|
|
resolution));
|
|
}
|
|
|
|
private static DenoImportKind DetermineImportKind(string target)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
{
|
|
return DenoImportKind.Unknown;
|
|
}
|
|
|
|
var lower = target.ToLowerInvariant();
|
|
if (lower.EndsWith(".json", StringComparison.Ordinal))
|
|
{
|
|
return DenoImportKind.JsonAssertion;
|
|
}
|
|
|
|
if (lower.EndsWith(".wasm", StringComparison.Ordinal))
|
|
{
|
|
return DenoImportKind.WasmAssertion;
|
|
}
|
|
|
|
if (lower.StartsWith("node:", StringComparison.Ordinal) ||
|
|
lower.StartsWith("deno:", StringComparison.Ordinal))
|
|
{
|
|
return DenoImportKind.BuiltIn;
|
|
}
|
|
|
|
return DenoImportKind.Static;
|
|
}
|
|
|
|
private static IEnumerable<string> SafeEnumerateFiles(string root)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
IEnumerable<string> iterator;
|
|
try
|
|
{
|
|
iterator = Directory.EnumerateFiles(root, "*", new EnumerationOptions
|
|
{
|
|
RecurseSubdirectories = true,
|
|
IgnoreInaccessible = true,
|
|
AttributesToSkip = FileAttributes.ReparsePoint,
|
|
ReturnSpecialDirectories = false,
|
|
});
|
|
}
|
|
catch (IOException)
|
|
{
|
|
yield break;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (var file in iterator)
|
|
{
|
|
yield return file;
|
|
}
|
|
}
|
|
|
|
private static string NormalizeSpecifier(string value)
|
|
=> $"alias::{(string.IsNullOrWhiteSpace(value) ? "(empty)" : value.Trim())}";
|
|
|
|
private static string NormalizePath(string value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? string.Empty
|
|
: value.Replace('\\', '/').TrimStart('/');
|
|
|
|
private static bool TryResolveUrlFromVendorPath(string vendorRelativePath, string fileRelativePath, out string? url)
|
|
{
|
|
var combined = Path.Combine(vendorRelativePath ?? string.Empty, fileRelativePath ?? string.Empty);
|
|
var normalized = NormalizePath(combined);
|
|
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
var schemeIndex = Array.FindIndex(segments, static segment => segment is "http" or "https");
|
|
if (schemeIndex < 0 || schemeIndex + 1 >= segments.Length)
|
|
{
|
|
url = null;
|
|
return false;
|
|
}
|
|
|
|
var scheme = segments[schemeIndex];
|
|
var host = segments[schemeIndex + 1];
|
|
var path = string.Join('/', segments[(schemeIndex + 2)..]);
|
|
url = path.Length > 0
|
|
? $"{scheme}://{host}/{path}"
|
|
: $"{scheme}://{host}";
|
|
return true;
|
|
}
|
|
|
|
private static bool TryResolveUrlFromCachePath(string relativePath, out string? url)
|
|
{
|
|
var normalized = NormalizePath(relativePath);
|
|
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
var depsIndex = Array.FindIndex(segments, static segment => segment.Equals("deps", StringComparison.OrdinalIgnoreCase));
|
|
if (depsIndex < 0 || depsIndex + 2 >= segments.Length)
|
|
{
|
|
url = null;
|
|
return false;
|
|
}
|
|
|
|
var scheme = segments[depsIndex + 1];
|
|
var host = segments[depsIndex + 2];
|
|
var remainingStart = depsIndex + 3;
|
|
if (remainingStart > segments.Length)
|
|
{
|
|
url = null;
|
|
return false;
|
|
}
|
|
|
|
var path = remainingStart < segments.Length
|
|
? string.Join('/', segments[remainingStart..])
|
|
: string.Empty;
|
|
|
|
url = path.Length > 0
|
|
? $"{scheme}://{host}/{path}"
|
|
: $"{scheme}://{host}";
|
|
return true;
|
|
}
|
|
}
|
|
}
|