feat: Implement Runtime Facts ingestion service and NDJSON reader
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Deno.Tests")]
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno;
|
||||
|
||||
public sealed class DenoAnalyzerPlugin : ILanguageAnalyzerPlugin
|
||||
{
|
||||
public string Name => "deno";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return new DenoLanguageAnalyzer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno;
|
||||
|
||||
public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
public string Id => "deno";
|
||||
|
||||
public string DisplayName => "Deno Analyzer";
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var workspace = await DenoWorkspaceNormalizer.NormalizeAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var moduleGraph = DenoModuleGraphResolver.Resolve(workspace, cancellationToken);
|
||||
var compatibility = DenoNpmCompatibilityAdapter.Analyze(workspace, moduleGraph, cancellationToken);
|
||||
var bundleScan = DenoBundleScanner.Scan(context.RootPath, cancellationToken);
|
||||
var bundleObservations = DenoBundleScanner.ToObservations(bundleScan);
|
||||
var containerInputs = DenoContainerAdapter.CollectInputs(workspace, bundleObservations);
|
||||
var containerRecords = DenoContainerEmitter.BuildRecords(Id, containerInputs);
|
||||
writer.AddRange(containerRecords);
|
||||
|
||||
var observationDocument = DenoObservationBuilder.Build(moduleGraph, compatibility, bundleObservations);
|
||||
var observationJson = DenoObservationSerializer.Serialize(observationDocument);
|
||||
var observationHash = DenoObservationSerializer.ComputeSha256(observationJson);
|
||||
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
|
||||
|
||||
var observationMetadata = new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("deno.observation.hash", observationHash),
|
||||
new KeyValuePair<string, string?>("deno.observation.entrypoints", observationDocument.Entrypoints.Length.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("deno.observation.capabilities", observationDocument.Capabilities.Length.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("deno.observation.bundles", observationDocument.Bundles.Length.ToString(CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
TryPersistObservation(context, observationBytes, observationMetadata);
|
||||
|
||||
var observationEvidence = new[]
|
||||
{
|
||||
new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Derived,
|
||||
"deno.observation",
|
||||
"document",
|
||||
observationJson,
|
||||
observationHash)
|
||||
};
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: "observation::deno",
|
||||
purl: null,
|
||||
name: "Deno Observation Summary",
|
||||
version: null,
|
||||
type: "deno-observation",
|
||||
metadata: observationMetadata,
|
||||
evidence: observationEvidence);
|
||||
|
||||
// Task 5+ will convert moduleGraph + compatibility and bundle insights into SBOM components and evidence records.
|
||||
GC.KeepAlive(moduleGraph);
|
||||
GC.KeepAlive(compatibility);
|
||||
GC.KeepAlive(bundleObservations);
|
||||
GC.KeepAlive(containerInputs);
|
||||
GC.KeepAlive(observationDocument);
|
||||
}
|
||||
|
||||
private void TryPersistObservation(
|
||||
LanguageAnalyzerContext context,
|
||||
byte[] observationBytes,
|
||||
IEnumerable<KeyValuePair<string, string?>> metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(observationBytes);
|
||||
|
||||
if (context.AnalysisStore is not { } analysisStore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadataDictionary = CreateMetadata(metadata);
|
||||
var payload = new AnalyzerObservationPayload(
|
||||
analyzerId: Id,
|
||||
kind: "deno.observation",
|
||||
mediaType: "application/json",
|
||||
content: observationBytes,
|
||||
metadata: metadataDictionary,
|
||||
view: "observations");
|
||||
|
||||
analysisStore.Set(ScanAnalysisKeys.DenoObservationPayload, payload);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?>? CreateMetadata(IEnumerable<KeyValuePair<string, string?>> metadata)
|
||||
{
|
||||
Dictionary<string, string?>? dictionary = null;
|
||||
foreach (var pair in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dictionary ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
dictionary[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
global using System;
|
||||
global using System.Buffers;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.IO;
|
||||
global using System.IO.Compression;
|
||||
global using System.Linq;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Globalization;
|
||||
global using System.Text;
|
||||
global using System.Text.RegularExpressions;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
global using StellaOps.Scanner.Analyzers.Lang;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBuiltinUsage(
|
||||
string Specifier,
|
||||
string SourceNodeId,
|
||||
string Provenance);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBundleInspectionResult(
|
||||
string SourcePath,
|
||||
string BundleType,
|
||||
string? Entrypoint,
|
||||
ImmutableArray<DenoBundleModule> Modules,
|
||||
ImmutableArray<DenoBundleResource> Resources)
|
||||
{
|
||||
public DenoBundleObservation ToObservation()
|
||||
=> new(SourcePath, BundleType, Entrypoint, Modules, Resources);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoBundleInspector
|
||||
{
|
||||
public static DenoBundleInspectionResult? TryInspect(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
return TryInspect(stream, path, cancellationToken);
|
||||
}
|
||||
|
||||
public static DenoBundleInspectionResult? TryInspect(Stream stream, string? sourcePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (stream is null || !stream.CanRead)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
sourcePath ??= "(stream)";
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var manifestEntry = archive.GetEntry("manifest.json") ?? archive.GetEntry("manifest");
|
||||
if (manifestEntry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var manifestStream = manifestEntry.Open();
|
||||
using var document = JsonDocument.Parse(manifestStream);
|
||||
var root = document.RootElement;
|
||||
|
||||
var entrypoint = root.TryGetProperty("entry", out var entryElement) && entryElement.ValueKind == JsonValueKind.String
|
||||
? entryElement.GetString()
|
||||
: null;
|
||||
|
||||
var modules = ParseModules(root, archive, cancellationToken);
|
||||
var resources = ParseResources(root);
|
||||
|
||||
return new DenoBundleInspectionResult(
|
||||
SourcePath: sourcePath,
|
||||
BundleType: "eszip",
|
||||
Entrypoint: entrypoint,
|
||||
Modules: modules,
|
||||
Resources: resources);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoBundleModule> ParseModules(JsonElement root, ZipArchive archive, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!root.TryGetProperty("modules", out var modulesElement) || modulesElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableArray<DenoBundleModule>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<DenoBundleModule>();
|
||||
foreach (var module in modulesElement.EnumerateObject())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var info = module.Value;
|
||||
var specifier = info.TryGetProperty("specifier", out var specifierElement) && specifierElement.ValueKind == JsonValueKind.String
|
||||
? specifierElement.GetString() ?? module.Name
|
||||
: module.Name;
|
||||
|
||||
var path = info.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
|
||||
? pathElement.GetString() ?? module.Name
|
||||
: module.Name;
|
||||
|
||||
var mediaType = info.TryGetProperty("mediaType", out var mediaTypeElement) && mediaTypeElement.ValueKind == JsonValueKind.String
|
||||
? mediaTypeElement.GetString()
|
||||
: null;
|
||||
|
||||
var checksum = info.TryGetProperty("checksum", out var checksumElement) && checksumElement.ValueKind == JsonValueKind.String
|
||||
? checksumElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
var entry = archive.GetEntry(path!);
|
||||
if (entry is null)
|
||||
{
|
||||
var normalized = path.Replace('\\', '/');
|
||||
entry = archive.GetEntry(normalized);
|
||||
}
|
||||
|
||||
if (entry is not null && string.IsNullOrWhiteSpace(checksum))
|
||||
{
|
||||
checksum = $"sha256:{ComputeSha256(entry)}";
|
||||
}
|
||||
}
|
||||
|
||||
builder.Add(new DenoBundleModule(
|
||||
specifier,
|
||||
path ?? module.Name,
|
||||
mediaType,
|
||||
checksum));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoBundleResource> ParseResources(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("resources", out var resourcesElement) || resourcesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return ImmutableArray<DenoBundleResource>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<DenoBundleResource>();
|
||||
foreach (var resource in resourcesElement.EnumerateArray())
|
||||
{
|
||||
if (resource.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = resource.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
|
||||
? nameElement.GetString() ?? "(resource)"
|
||||
: "(resource)";
|
||||
|
||||
var mediaType = resource.TryGetProperty("mediaType", out var mediaElement) && mediaElement.ValueKind == JsonValueKind.String
|
||||
? mediaElement.GetString()
|
||||
: null;
|
||||
|
||||
var size = resource.TryGetProperty("size", out var sizeElement) && sizeElement.ValueKind == JsonValueKind.Number
|
||||
? sizeElement.GetInt64()
|
||||
: 0;
|
||||
|
||||
builder.Add(new DenoBundleResource(name!, mediaType, size));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ZipArchiveEntry entry)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = entry.Open();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBundleModule(
|
||||
string Specifier,
|
||||
string Path,
|
||||
string? MediaType,
|
||||
string? Checksum);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBundleObservation(
|
||||
string SourcePath,
|
||||
string BundleType,
|
||||
string? Entrypoint,
|
||||
ImmutableArray<DenoBundleModule> Modules,
|
||||
ImmutableArray<DenoBundleResource> Resources);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBundleResource(
|
||||
string Name,
|
||||
string? MediaType,
|
||||
long SizeBytes);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoBundleScanResult(
|
||||
ImmutableArray<DenoBundleInspectionResult> EszipBundles,
|
||||
ImmutableArray<DenoBundleInspectionResult> CompiledBundles);
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoBundleScanner
|
||||
{
|
||||
public static DenoBundleScanResult Scan(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return new DenoBundleScanResult(
|
||||
ImmutableArray<DenoBundleInspectionResult>.Empty,
|
||||
ImmutableArray<DenoBundleInspectionResult>.Empty);
|
||||
}
|
||||
|
||||
var eszipBuilder = ImmutableArray.CreateBuilder<DenoBundleInspectionResult>();
|
||||
var compileBuilder = ImmutableArray.CreateBuilder<DenoBundleInspectionResult>();
|
||||
|
||||
foreach (var eszipPath in SafeEnumerateFiles(rootPath, "*.eszip"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = DenoBundleInspector.TryInspect(eszipPath, cancellationToken);
|
||||
if (result is not null)
|
||||
{
|
||||
eszipBuilder.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var binaryPath in SafeEnumerateFiles(rootPath, "*.deno"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = DenoCompileInspector.TryInspect(binaryPath, cancellationToken);
|
||||
if (result is not null)
|
||||
{
|
||||
compileBuilder.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return new DenoBundleScanResult(eszipBuilder.ToImmutable(), compileBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
public static ImmutableArray<DenoBundleObservation> ToObservations(DenoBundleScanResult scanResult)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoBundleObservation>();
|
||||
foreach (var bundle in scanResult.EszipBundles)
|
||||
{
|
||||
builder.Add(bundle.ToObservation());
|
||||
}
|
||||
|
||||
foreach (var bundle in scanResult.CompiledBundles)
|
||||
{
|
||||
builder.Add(bundle.ToObservation());
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateFiles(string rootPath, string pattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(
|
||||
rootPath,
|
||||
pattern,
|
||||
new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
ReturnSpecialDirectories = false
|
||||
});
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoCacheLocationKind
|
||||
{
|
||||
Workspace,
|
||||
Env,
|
||||
Home,
|
||||
Layer,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
internal sealed class DenoCacheLocation
|
||||
{
|
||||
public DenoCacheLocation(string absolutePath, string alias, DenoCacheLocationKind kind, string? layerDigest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
throw new ArgumentException("Path is required", nameof(absolutePath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
throw new ArgumentException("Alias is required", nameof(alias));
|
||||
}
|
||||
|
||||
AbsolutePath = Path.GetFullPath(absolutePath);
|
||||
Alias = alias;
|
||||
Kind = kind;
|
||||
LayerDigest = layerDigest;
|
||||
}
|
||||
|
||||
public string AbsolutePath { get; }
|
||||
|
||||
public string Alias { get; }
|
||||
|
||||
public DenoCacheLocationKind Kind { get; }
|
||||
|
||||
public string? LayerDigest { get; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoCapabilityRecord(
|
||||
DenoCapabilityType Capability,
|
||||
string ReasonCode,
|
||||
ImmutableArray<string> Sources);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoCapabilityType
|
||||
{
|
||||
FileSystem,
|
||||
Network,
|
||||
Environment,
|
||||
Process,
|
||||
Crypto,
|
||||
Ffi,
|
||||
Worker,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoCompatibilityAnalysis(
|
||||
ImmutableArray<DenoBuiltinUsage> BuiltinUsages,
|
||||
ImmutableArray<DenoNpmResolution> NpmResolutions,
|
||||
ImmutableArray<DenoCapabilityRecord> Capabilities,
|
||||
ImmutableArray<DenoDynamicImportObservation> DynamicImports,
|
||||
ImmutableArray<DenoLiteralFetchObservation> LiteralFetches);
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoCompileInspector
|
||||
{
|
||||
internal const string EszipMarker = "DENO_COMPILE_ESZIP_START";
|
||||
|
||||
public static DenoBundleInspectionResult? TryInspect(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = File.ReadAllBytes(path);
|
||||
var marker = Encoding.UTF8.GetBytes(EszipMarker);
|
||||
var index = IndexOf(data, marker);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var start = index + marker.Length;
|
||||
if (start >= data.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream(data, start, data.Length - start);
|
||||
return DenoBundleInspector.TryInspect(ms, path, cancellationToken)?.WithBundleType("deno-compile");
|
||||
}
|
||||
|
||||
private static int IndexOf(ReadOnlySpan<byte> data, ReadOnlySpan<byte> pattern)
|
||||
{
|
||||
if (pattern.Length == 0 || pattern.Length > data.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (var i = 0; i <= data.Length - pattern.Length; i++)
|
||||
{
|
||||
if (data.Slice(i, pattern.Length).SequenceEqual(pattern))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static DenoBundleInspectionResult? WithBundleType(this DenoBundleInspectionResult? result, string bundleType)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result with { BundleType = bundleType };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoConfigDocument
|
||||
{
|
||||
private DenoConfigDocument(
|
||||
string absolutePath,
|
||||
string relativePath,
|
||||
string? importMapPath,
|
||||
DenoImportMapDocument? inlineImportMap,
|
||||
bool lockEnabled,
|
||||
string? lockFilePath,
|
||||
bool vendorEnabled,
|
||||
string? vendorDirectoryPath,
|
||||
bool nodeModulesDirEnabled,
|
||||
string? nodeModulesDir)
|
||||
{
|
||||
AbsolutePath = Path.GetFullPath(absolutePath);
|
||||
RelativePath = DenoPathUtilities.NormalizeRelativePath(relativePath);
|
||||
DirectoryPath = Path.GetDirectoryName(AbsolutePath) ?? AbsolutePath;
|
||||
ImportMapPath = importMapPath;
|
||||
InlineImportMap = inlineImportMap;
|
||||
LockEnabled = lockEnabled;
|
||||
LockFilePath = lockFilePath;
|
||||
VendorEnabled = vendorEnabled;
|
||||
VendorDirectoryPath = vendorDirectoryPath;
|
||||
NodeModulesDirEnabled = nodeModulesDirEnabled;
|
||||
NodeModulesDirectory = nodeModulesDir;
|
||||
}
|
||||
|
||||
public string AbsolutePath { get; }
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public string DirectoryPath { get; }
|
||||
|
||||
public string? ImportMapPath { get; }
|
||||
|
||||
public DenoImportMapDocument? InlineImportMap { get; }
|
||||
|
||||
public bool LockEnabled { get; }
|
||||
|
||||
public string? LockFilePath { get; }
|
||||
|
||||
public bool VendorEnabled { get; }
|
||||
|
||||
public string? VendorDirectoryPath { get; }
|
||||
|
||||
public bool NodeModulesDirEnabled { get; }
|
||||
|
||||
public string? NodeModulesDirectory { get; }
|
||||
|
||||
public static bool TryLoad(
|
||||
string absolutePath,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken,
|
||||
out DenoConfigDocument? document)
|
||||
{
|
||||
document = null;
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var json = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var root = json.RootElement;
|
||||
var directory = Path.GetDirectoryName(absolutePath) ?? Path.GetDirectoryName(Path.GetFullPath(absolutePath)) ?? absolutePath;
|
||||
|
||||
var importMapPath = ResolveImportMapPath(root, directory);
|
||||
var inlineImportMap = ResolveInlineImportMap(root, relativePath, directory);
|
||||
var (lockEnabled, lockFilePath) = ResolveLockPath(root, directory);
|
||||
var (vendorEnabled, vendorDirectory) = ResolveVendorDirectory(root, directory);
|
||||
var (nodeModulesDirEnabled, nodeModulesDir) = ResolveNodeModulesDirectory(root, directory);
|
||||
|
||||
document = new DenoConfigDocument(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
string.IsNullOrWhiteSpace(importMapPath) ? null : importMapPath,
|
||||
inlineImportMap,
|
||||
lockEnabled,
|
||||
lockFilePath,
|
||||
vendorEnabled,
|
||||
vendorDirectory,
|
||||
nodeModulesDirEnabled,
|
||||
nodeModulesDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveImportMapPath(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("importMap", out var importMapElement) || importMapElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = importMapElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = DenoPathUtilities.ResolvePath(directory, candidate);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
private static DenoImportMapDocument? ResolveInlineImportMap(JsonElement root, string relativePath, string directory)
|
||||
{
|
||||
var imports = ExtractInlineMap(root, "imports");
|
||||
var scopes = ExtractInlineScopes(root, "scopes");
|
||||
if (imports.Count == 0 && scopes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DenoImportMapDocument.CreateInline(
|
||||
origin: $"inline::{relativePath}",
|
||||
imports,
|
||||
scopes);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ExtractInlineMap(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var element))
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return ExtractInlineMap(element);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ExtractInlineMap(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var results = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var entry in element.EnumerateObject())
|
||||
{
|
||||
if (entry.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var specifier = entry.Name.Trim();
|
||||
if (specifier.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results[specifier] = entry.Value.GetString()?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Dictionary<string, IDictionary<string, string>> ExtractInlineScopes(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var element) || element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, IDictionary<string, string>>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var results = new Dictionary<string, IDictionary<string, string>>(StringComparer.Ordinal);
|
||||
foreach (var scope in element.EnumerateObject())
|
||||
{
|
||||
var map = ExtractInlineMap(scope.Value);
|
||||
if (map.Count > 0)
|
||||
{
|
||||
results[scope.Name.Trim()] = map;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static (bool Enabled, string? Path) ResolveLockPath(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("lock", out var lockElement))
|
||||
{
|
||||
var defaultPath = Path.Combine(directory, "deno.lock");
|
||||
return File.Exists(defaultPath)
|
||||
? (true, defaultPath)
|
||||
: (false, null);
|
||||
}
|
||||
|
||||
switch (lockElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.False:
|
||||
return (false, null);
|
||||
case JsonValueKind.True:
|
||||
{
|
||||
var defaultPath = Path.Combine(directory, "deno.lock");
|
||||
return (true, defaultPath);
|
||||
}
|
||||
case JsonValueKind.String:
|
||||
{
|
||||
var candidate = lockElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var resolved = DenoPathUtilities.ResolvePath(directory, candidate);
|
||||
return (File.Exists(resolved), File.Exists(resolved) ? resolved : null);
|
||||
}
|
||||
case JsonValueKind.Object:
|
||||
{
|
||||
var enabled = true;
|
||||
string? candidatePath = null;
|
||||
if (lockElement.TryGetProperty("enabled", out var enabledElement))
|
||||
{
|
||||
enabled = enabledElement.ValueKind != JsonValueKind.False;
|
||||
}
|
||||
|
||||
if (lockElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
candidatePath = pathElement.GetString();
|
||||
}
|
||||
|
||||
var resolved = string.IsNullOrWhiteSpace(candidatePath)
|
||||
? Path.Combine(directory, "deno.lock")
|
||||
: DenoPathUtilities.ResolvePath(directory, candidatePath!);
|
||||
|
||||
if (!File.Exists(resolved))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
return (enabled, enabled ? resolved : null);
|
||||
}
|
||||
default:
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (bool Enabled, string? Directory) ResolveVendorDirectory(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("vendor", out var vendorElement))
|
||||
{
|
||||
var defaultPath = Path.Combine(directory, "vendor");
|
||||
return Directory.Exists(defaultPath) ? (true, defaultPath) : (false, null);
|
||||
}
|
||||
|
||||
switch (vendorElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.False:
|
||||
return (false, null);
|
||||
case JsonValueKind.True:
|
||||
{
|
||||
var defaultPath = Path.Combine(directory, "vendor");
|
||||
return Directory.Exists(defaultPath) ? (true, defaultPath) : (true, defaultPath);
|
||||
}
|
||||
case JsonValueKind.String:
|
||||
{
|
||||
var candidate = vendorElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var resolved = DenoPathUtilities.ResolvePath(directory, candidate);
|
||||
return Directory.Exists(resolved) ? (true, resolved) : (false, null);
|
||||
}
|
||||
case JsonValueKind.Object:
|
||||
{
|
||||
bool enabled = true;
|
||||
string? candidatePath = null;
|
||||
|
||||
if (vendorElement.TryGetProperty("enabled", out var enabledElement))
|
||||
{
|
||||
enabled = enabledElement.ValueKind != JsonValueKind.False;
|
||||
}
|
||||
|
||||
if (vendorElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
candidatePath = pathElement.GetString();
|
||||
}
|
||||
|
||||
var resolved = string.IsNullOrWhiteSpace(candidatePath)
|
||||
? Path.Combine(directory, "vendor")
|
||||
: DenoPathUtilities.ResolvePath(directory, candidatePath!);
|
||||
|
||||
if (!Directory.Exists(resolved))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
return (enabled, resolved);
|
||||
}
|
||||
default:
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (bool Enabled, string? Directory) ResolveNodeModulesDirectory(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("nodeModulesDir", out var nodeModulesElement))
|
||||
{
|
||||
var defaultPath = Path.Combine(directory, "node_modules");
|
||||
return Directory.Exists(defaultPath) ? (true, defaultPath) : (false, null);
|
||||
}
|
||||
|
||||
return nodeModulesElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.False => (false, null),
|
||||
JsonValueKind.True => (true, Path.Combine(directory, "node_modules")),
|
||||
JsonValueKind.String => (true, DenoPathUtilities.ResolvePath(directory, nodeModulesElement.GetString() ?? string.Empty)),
|
||||
_ => (false, null),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoContainerAdapter
|
||||
{
|
||||
public static ImmutableArray<DenoContainerInput> CollectInputs(
|
||||
DenoWorkspace workspace,
|
||||
ImmutableArray<DenoBundleObservation> bundleObservations)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoContainerInput>();
|
||||
AddCaches(workspace, builder);
|
||||
AddVendors(workspace, builder);
|
||||
AddBundles(bundleObservations, builder);
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static void AddCaches(DenoWorkspace workspace, ImmutableArray<DenoContainerInput>.Builder builder)
|
||||
{
|
||||
foreach (var cache in workspace.CacheLocations)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["path"] = cache.AbsolutePath,
|
||||
["alias"] = cache.Alias,
|
||||
["kind"] = cache.Kind.ToString()
|
||||
};
|
||||
|
||||
builder.Add(new DenoContainerInput(
|
||||
DenoContainerSourceKind.Cache,
|
||||
cache.Alias,
|
||||
cache.LayerDigest,
|
||||
metadata,
|
||||
Bundle: null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddVendors(DenoWorkspace workspace, ImmutableArray<DenoContainerInput>.Builder builder)
|
||||
{
|
||||
foreach (var vendor in workspace.Vendors)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["path"] = vendor.AbsolutePath,
|
||||
["alias"] = vendor.Alias
|
||||
};
|
||||
|
||||
builder.Add(new DenoContainerInput(
|
||||
DenoContainerSourceKind.Vendor,
|
||||
vendor.Alias,
|
||||
vendor.LayerDigest,
|
||||
metadata,
|
||||
Bundle: null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddBundles(
|
||||
ImmutableArray<DenoBundleObservation> bundleObservations,
|
||||
ImmutableArray<DenoContainerInput>.Builder builder)
|
||||
{
|
||||
foreach (var bundle in bundleObservations)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["entrypoint"] = bundle.Entrypoint,
|
||||
["moduleCount"] = bundle.Modules.Length.ToString(CultureInfo.InvariantCulture),
|
||||
["resourceCount"] = bundle.Resources.Length.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
builder.Add(new DenoContainerInput(
|
||||
DenoContainerSourceKind.Bundle,
|
||||
bundle.SourcePath,
|
||||
layerDigest: null,
|
||||
metadata,
|
||||
bundle));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoContainerEmitter
|
||||
{
|
||||
private const string ComponentType = "deno-container";
|
||||
private const string MetadataPrefix = "deno.container";
|
||||
|
||||
public static ImmutableArray<LanguageComponentRecord> BuildRecords(
|
||||
string analyzerId,
|
||||
IEnumerable<DenoContainerInput> inputs)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<LanguageComponentRecord>();
|
||||
foreach (var input in inputs ?? Array.Empty<DenoContainerInput>())
|
||||
{
|
||||
var componentKey = $"container::{input.Kind}:{input.Identifier}".ToLowerInvariant();
|
||||
var metadata = BuildMetadata(input);
|
||||
var evidence = BuildEvidence(input);
|
||||
|
||||
builder.Add(
|
||||
LanguageComponentRecord.FromExplicitKey(
|
||||
analyzerId,
|
||||
componentKey,
|
||||
purl: null,
|
||||
name: input.Identifier,
|
||||
version: null,
|
||||
type: ComponentType,
|
||||
metadata,
|
||||
evidence));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> BuildMetadata(DenoContainerInput input)
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string?>>(input.Metadata?.Count ?? 0 + 4)
|
||||
{
|
||||
new($"{MetadataPrefix}.kind", input.Kind.ToString().ToLowerInvariant()),
|
||||
new($"{MetadataPrefix}.identifier", input.Identifier)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.LayerDigest))
|
||||
{
|
||||
metadata.Add(new($"{MetadataPrefix}.layerDigest", input.LayerDigest));
|
||||
}
|
||||
|
||||
if (input.Metadata is not null)
|
||||
{
|
||||
foreach (var kvp in input.Metadata)
|
||||
{
|
||||
metadata.Add(new($"{MetadataPrefix}.meta.{kvp.Key}", kvp.Value));
|
||||
}
|
||||
}
|
||||
|
||||
if (input.Bundle is not null)
|
||||
{
|
||||
metadata.Add(new($"{MetadataPrefix}.bundle.entrypoint", input.Bundle.Entrypoint));
|
||||
metadata.Add(new($"{MetadataPrefix}.bundle.modules", input.Bundle.Modules.Length.ToString(CultureInfo.InvariantCulture)));
|
||||
metadata.Add(new($"{MetadataPrefix}.bundle.resources", input.Bundle.Resources.Length.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static IEnumerable<LanguageComponentEvidence> BuildEvidence(DenoContainerInput input)
|
||||
{
|
||||
var evidence = new List<LanguageComponentEvidence>
|
||||
{
|
||||
new(LanguageEvidenceKind.Metadata, "deno.container", input.Kind.ToString(), input.Identifier, input.LayerDigest)
|
||||
};
|
||||
|
||||
if (input.Bundle is not null && !string.IsNullOrWhiteSpace(input.Bundle.SourcePath))
|
||||
{
|
||||
evidence.Add(new(
|
||||
LanguageEvidenceKind.File,
|
||||
"deno.bundle",
|
||||
input.Bundle.SourcePath!,
|
||||
input.Bundle.Entrypoint,
|
||||
null));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoContainerInput(
|
||||
DenoContainerSourceKind Kind,
|
||||
string Identifier,
|
||||
string? LayerDigest,
|
||||
IReadOnlyDictionary<string, string?> Metadata,
|
||||
DenoBundleObservation? Bundle);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoContainerSourceKind
|
||||
{
|
||||
Cache,
|
||||
Vendor,
|
||||
Bundle,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoDynamicImportObservation(
|
||||
string FilePath,
|
||||
int Line,
|
||||
string Specifier,
|
||||
string ReasonCode);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoImportKind
|
||||
{
|
||||
Static,
|
||||
Dynamic,
|
||||
JsonAssertion,
|
||||
WasmAssertion,
|
||||
BuiltIn,
|
||||
Redirect,
|
||||
Cache,
|
||||
Dependency,
|
||||
NpmBridge,
|
||||
Unknown,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoImportMapDocument
|
||||
{
|
||||
private DenoImportMapDocument(
|
||||
string origin,
|
||||
string sortKey,
|
||||
string? absolutePath,
|
||||
bool isInline,
|
||||
ImmutableDictionary<string, string> imports,
|
||||
ImmutableDictionary<string, ImmutableDictionary<string, string>> scopes)
|
||||
{
|
||||
Origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
SortKey = sortKey ?? throw new ArgumentNullException(nameof(sortKey));
|
||||
AbsolutePath = absolutePath;
|
||||
IsInline = isInline;
|
||||
Imports = imports ?? ImmutableDictionary<string, string>.Empty;
|
||||
Scopes = scopes ?? ImmutableDictionary<string, ImmutableDictionary<string, string>>.Empty;
|
||||
}
|
||||
|
||||
public string Origin { get; }
|
||||
|
||||
public string SortKey { get; }
|
||||
|
||||
public string? AbsolutePath { get; }
|
||||
|
||||
public bool IsInline { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Imports { get; }
|
||||
|
||||
public ImmutableDictionary<string, ImmutableDictionary<string, string>> Scopes { get; }
|
||||
|
||||
public static bool TryLoadFromFile(
|
||||
string absolutePath,
|
||||
string origin,
|
||||
out DenoImportMapDocument? document)
|
||||
{
|
||||
document = null;
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var json = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
|
||||
var root = json.RootElement;
|
||||
var imports = ParseImportMap(root.TryGetProperty("imports", out var importsElement) ? importsElement : default);
|
||||
var scopes = ParseScopes(root.TryGetProperty("scopes", out var scopesElement) ? scopesElement : default);
|
||||
|
||||
var sortKey = $"file::{DenoPathUtilities.NormalizeRelativePath(origin)}";
|
||||
document = new DenoImportMapDocument(
|
||||
origin,
|
||||
sortKey,
|
||||
Path.GetFullPath(absolutePath),
|
||||
isInline: false,
|
||||
imports,
|
||||
scopes);
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static DenoImportMapDocument CreateInline(
|
||||
string origin,
|
||||
IDictionary<string, string> imports,
|
||||
IDictionary<string, IDictionary<string, string>> scopes)
|
||||
{
|
||||
var normalizedOrigin = string.IsNullOrWhiteSpace(origin) ? "inline" : origin;
|
||||
var importMap = imports is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: imports.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
|
||||
var scopeMap = scopes is null
|
||||
? ImmutableDictionary<string, ImmutableDictionary<string, string>>.Empty
|
||||
: scopes.ToImmutableDictionary(
|
||||
static scope => scope.Key,
|
||||
static scope => scope.Value?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var sortKey = $"inline::{normalizedOrigin}";
|
||||
return new DenoImportMapDocument(
|
||||
normalizedOrigin,
|
||||
sortKey,
|
||||
absolutePath: null,
|
||||
isInline: true,
|
||||
importMap,
|
||||
scopeMap);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseImportMap(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var specifier = property.Name.Trim();
|
||||
var target = property.Value.GetString()?.Trim() ?? string.Empty;
|
||||
if (specifier.Length > 0)
|
||||
{
|
||||
builder[specifier] = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, ImmutableDictionary<string, string>> ParseScopes(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableDictionary<string, ImmutableDictionary<string, string>>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, ImmutableDictionary<string, string>>(StringComparer.Ordinal);
|
||||
foreach (var scope in element.EnumerateObject())
|
||||
{
|
||||
if (scope.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var map = ParseImportMap(scope.Value);
|
||||
if (!map.IsEmpty)
|
||||
{
|
||||
builder[scope.Name.Trim()] = map;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoLayerMetadata
|
||||
{
|
||||
public static string? TryExtractDigest(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path
|
||||
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sha256-", StringComparison.OrdinalIgnoreCase) && trimmed.Length > 7)
|
||||
{
|
||||
var digest = trimmed[7..];
|
||||
if (IsHex(digest))
|
||||
{
|
||||
return digest.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.Length == 64 && IsHex(trimmed))
|
||||
{
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsHex(string value)
|
||||
=> value.All(static c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoLiteralFetchObservation(
|
||||
string FilePath,
|
||||
int Line,
|
||||
string Url,
|
||||
string ReasonCode);
|
||||
@@ -0,0 +1,208 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoLockFile
|
||||
{
|
||||
private DenoLockFile(
|
||||
string absolutePath,
|
||||
string relativePath,
|
||||
string version,
|
||||
ImmutableDictionary<string, string> remoteEntries,
|
||||
ImmutableDictionary<string, string> redirectEntries,
|
||||
ImmutableDictionary<string, DenoLockNpmPackage> npmPackages,
|
||||
ImmutableDictionary<string, string> npmSpecifiers)
|
||||
{
|
||||
AbsolutePath = Path.GetFullPath(absolutePath);
|
||||
RelativePath = DenoPathUtilities.NormalizeRelativePath(relativePath);
|
||||
Version = version;
|
||||
RemoteEntries = remoteEntries;
|
||||
Redirects = redirectEntries;
|
||||
NpmPackages = npmPackages;
|
||||
NpmSpecifiers = npmSpecifiers;
|
||||
}
|
||||
|
||||
public string AbsolutePath { get; }
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> RemoteEntries { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Redirects { get; }
|
||||
|
||||
public ImmutableDictionary<string, DenoLockNpmPackage> NpmPackages { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> NpmSpecifiers { get; }
|
||||
|
||||
public static bool TryLoad(string absolutePath, string relativePath, out DenoLockFile? lockFile)
|
||||
{
|
||||
lockFile = null;
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var json = JsonDocument.Parse(stream, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
|
||||
var root = json.RootElement;
|
||||
var version = root.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
|
||||
? versionElement.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var remote = ParseRemoteEntries(root);
|
||||
var redirects = ParseRedirects(root);
|
||||
var (npmPackages, npmSpecifiers) = ParseNpmEntries(root);
|
||||
|
||||
lockFile = new DenoLockFile(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
version,
|
||||
remote,
|
||||
redirects,
|
||||
npmPackages,
|
||||
npmSpecifiers);
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseRemoteEntries(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("remote", out var remoteElement) || remoteElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var entry in remoteElement.EnumerateObject())
|
||||
{
|
||||
if (entry.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
builder[entry.Name] = entry.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseRedirects(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("redirects", out var redirectsElement) || redirectsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var entry in redirectsElement.EnumerateObject())
|
||||
{
|
||||
if (entry.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
builder[entry.Name] = entry.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static (ImmutableDictionary<string, DenoLockNpmPackage> Packages, ImmutableDictionary<string, string> Specifiers) ParseNpmEntries(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("npm", out var npmElement) || npmElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return (ImmutableDictionary<string, DenoLockNpmPackage>.Empty, ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
var packages = ImmutableDictionary.CreateBuilder<string, DenoLockNpmPackage>(StringComparer.OrdinalIgnoreCase);
|
||||
if (npmElement.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var packageEntry in packagesElement.EnumerateObject())
|
||||
{
|
||||
if (packageEntry.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var package = DenoLockNpmPackage.Create(packageEntry.Name, packageEntry.Value);
|
||||
if (package is not null)
|
||||
{
|
||||
packages[package.EntryId] = package;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var specifiers = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
if (npmElement.TryGetProperty("specifiers", out var specifiersElement) && specifiersElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var specifier in specifiersElement.EnumerateObject())
|
||||
{
|
||||
if (specifier.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
specifiers[specifier.Name] = specifier.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (packages.ToImmutable(), specifiers.ToImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DenoLockNpmPackage
|
||||
{
|
||||
private DenoLockNpmPackage(
|
||||
string entryId,
|
||||
string integrity,
|
||||
ImmutableDictionary<string, string> dependencies)
|
||||
{
|
||||
EntryId = entryId;
|
||||
Integrity = integrity;
|
||||
Dependencies = dependencies;
|
||||
}
|
||||
|
||||
public string EntryId { get; }
|
||||
|
||||
public string Integrity { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Dependencies { get; }
|
||||
|
||||
public static DenoLockNpmPackage? Create(string entryId, JsonElement element)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryId) || element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var integrity = element.TryGetProperty("integrity", out var integrityElement) && integrityElement.ValueKind == JsonValueKind.String
|
||||
? integrityElement.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var dependencies = ImmutableDictionary<string, string>.Empty;
|
||||
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dependency in dependenciesElement.EnumerateObject())
|
||||
{
|
||||
if (dependency.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
builder[dependency.Name] = dependency.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
dependencies = builder.ToImmutable();
|
||||
}
|
||||
|
||||
return new DenoLockNpmPackage(entryId, integrity, dependencies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoModuleEdge(
|
||||
string FromId,
|
||||
string ToId,
|
||||
DenoImportKind ImportKind,
|
||||
string Specifier,
|
||||
string Provenance,
|
||||
string? Resolution);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoModuleGraph
|
||||
{
|
||||
public DenoModuleGraph(
|
||||
IEnumerable<DenoModuleNode> nodes,
|
||||
IEnumerable<DenoModuleEdge> edges)
|
||||
{
|
||||
Nodes = nodes?
|
||||
.Where(static node => node is not null)
|
||||
.OrderBy(static node => node.Id, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
?? ImmutableArray<DenoModuleNode>.Empty;
|
||||
|
||||
Edges = edges?
|
||||
.Where(static edge => edge is not null)
|
||||
.OrderBy(static edge => edge.FromId, StringComparer.Ordinal)
|
||||
.ThenBy(static edge => edge.ToId, StringComparer.Ordinal)
|
||||
.ThenBy(static edge => edge.Specifier, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
?? ImmutableArray<DenoModuleEdge>.Empty;
|
||||
}
|
||||
|
||||
public ImmutableArray<DenoModuleNode> Nodes { get; }
|
||||
|
||||
public ImmutableArray<DenoModuleEdge> Edges { get; }
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoModuleKind
|
||||
{
|
||||
WorkspaceConfig,
|
||||
ImportMap,
|
||||
SpecifierAlias,
|
||||
WorkspaceModule,
|
||||
VendorModule,
|
||||
RemoteModule,
|
||||
CacheEntry,
|
||||
NpmSpecifier,
|
||||
NpmPackage,
|
||||
BuiltInModule,
|
||||
LockFile,
|
||||
Unknown,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoModuleNode(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
DenoModuleKind Kind,
|
||||
string? Reference,
|
||||
string? LayerDigest,
|
||||
string? Integrity,
|
||||
IReadOnlyDictionary<string, string?> Metadata);
|
||||
@@ -0,0 +1,673 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoNpmCompatibilityAdapter
|
||||
{
|
||||
private static readonly string[] ConditionPriority =
|
||||
{
|
||||
"deno",
|
||||
"import",
|
||||
"module",
|
||||
"browser",
|
||||
"worker",
|
||||
"default"
|
||||
};
|
||||
|
||||
private static readonly Regex DynamicImportRegex = new(@"import\s*\(\s*['""](?<url>https?://[^'""]+)['""]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex LiteralFetchRegex = new(@"fetch\s*\(\s*['""](?<url>https?://[^'""]+)['""]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> SourceFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"
|
||||
};
|
||||
|
||||
private static readonly (string Prefix, (DenoCapabilityType Capability, string ReasonCode)[] Entries)[] BuiltinCapabilitySignatures =
|
||||
{
|
||||
("node:fs", new[] { (DenoCapabilityType.FileSystem, "builtin.node.fs") }),
|
||||
("deno:fs", new[] { (DenoCapabilityType.FileSystem, "builtin.deno.fs") }),
|
||||
("node:net", new[] { (DenoCapabilityType.Network, "builtin.node.net") }),
|
||||
("node:http", new[] { (DenoCapabilityType.Network, "builtin.node.http") }),
|
||||
("node:https", new[] { (DenoCapabilityType.Network, "builtin.node.https") }),
|
||||
("deno:net", new[] { (DenoCapabilityType.Network, "builtin.deno.net") }),
|
||||
("node:process", new[]
|
||||
{
|
||||
(DenoCapabilityType.Process, "builtin.node.process"),
|
||||
(DenoCapabilityType.Environment, "builtin.node.env")
|
||||
}),
|
||||
("deno:env", new[] { (DenoCapabilityType.Environment, "builtin.deno.env") }),
|
||||
("deno:permissions", new[] { (DenoCapabilityType.Environment, "builtin.deno.permissions") }),
|
||||
("node:crypto", new[] { (DenoCapabilityType.Crypto, "builtin.node.crypto") }),
|
||||
("deno:crypto", new[] { (DenoCapabilityType.Crypto, "builtin.deno.crypto") }),
|
||||
("deno:ffi", new[] { (DenoCapabilityType.Ffi, "builtin.deno.ffi") }),
|
||||
("node:worker_threads", new[] { (DenoCapabilityType.Worker, "builtin.node.worker_threads") }),
|
||||
("deno:worker", new[] { (DenoCapabilityType.Worker, "builtin.deno.worker") }),
|
||||
("deno:shared_worker", new[] { (DenoCapabilityType.Worker, "builtin.deno.shared_worker") })
|
||||
};
|
||||
|
||||
public static DenoCompatibilityAnalysis Analyze(
|
||||
DenoWorkspace workspace,
|
||||
DenoModuleGraph graph,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workspace);
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var builtins = CollectBuiltins(graph);
|
||||
var npmResolutions = ResolveNpmPackages(workspace, graph, cancellationToken);
|
||||
var (dynamicImports, literalFetches) = AnalyzeSourceFiles(workspace, cancellationToken);
|
||||
var capabilityRecords = BuildCapabilities(builtins, graph, dynamicImports, literalFetches);
|
||||
|
||||
return new DenoCompatibilityAnalysis(
|
||||
builtins,
|
||||
npmResolutions,
|
||||
capabilityRecords,
|
||||
dynamicImports,
|
||||
literalFetches);
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoBuiltinUsage> CollectBuiltins(DenoModuleGraph graph)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoBuiltinUsage>();
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (edge.Specifier.StartsWith("node:", StringComparison.OrdinalIgnoreCase) ||
|
||||
edge.Specifier.StartsWith("deno:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Add(new DenoBuiltinUsage(edge.Specifier, edge.FromId, edge.Provenance));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoNpmResolution> ResolveNpmPackages(
|
||||
DenoWorkspace workspace,
|
||||
DenoModuleGraph graph,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var specToPackage = BuildSpecifierMap(workspace);
|
||||
var packageInfos = BuildPackageInfos(specToPackage.Values.Distinct(), workspace.CacheLocations);
|
||||
|
||||
try
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoNpmResolution>();
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!edge.Specifier.StartsWith("npm:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!specToPackage.TryGetValue(edge.Specifier, out var packageKey))
|
||||
{
|
||||
if (!TryParseNpmSpecifier(edge.Specifier, out var parsedName, out _, out _, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
packageKey = packageInfos.Keys.FirstOrDefault(key =>
|
||||
string.Equals(key.Name, parsedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (packageKey.Name is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!packageInfos.TryGetValue(packageKey, out var packageInfo))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseNpmSpecifier(edge.Specifier, out _, out _, out var subpath, out _))
|
||||
{
|
||||
subpath = string.Empty;
|
||||
}
|
||||
|
||||
var (target, condition) = packageInfo.ResolveExport(subpath);
|
||||
string? resolvedPath = null;
|
||||
var exists = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target) && !string.IsNullOrWhiteSpace(packageInfo.RootPath))
|
||||
{
|
||||
var normalizedTarget = NormalizePath(target!);
|
||||
resolvedPath = Path.GetFullPath(Path.Combine(packageInfo.RootPath!, normalizedTarget));
|
||||
exists = File.Exists(resolvedPath) || Directory.Exists(resolvedPath);
|
||||
}
|
||||
|
||||
builder.Add(new DenoNpmResolution(
|
||||
edge.Specifier,
|
||||
packageInfo.Name,
|
||||
packageInfo.Version,
|
||||
edge.FromId,
|
||||
condition,
|
||||
target,
|
||||
resolvedPath,
|
||||
exists));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var info in packageInfos.Values)
|
||||
{
|
||||
info.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, NpmPackageKey> BuildSpecifierMap(DenoWorkspace workspace)
|
||||
{
|
||||
var map = new Dictionary<string, NpmPackageKey>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var lockFile in workspace.LockFiles)
|
||||
{
|
||||
foreach (var entry in lockFile.NpmSpecifiers)
|
||||
{
|
||||
if (!TryParseLockSpecifier(entry.Value, out var name, out var version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[entry.Key] = new NpmPackageKey(name, version);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Dictionary<NpmPackageKey, NpmPackageInfo> BuildPackageInfos(
|
||||
IEnumerable<NpmPackageKey> keys,
|
||||
IEnumerable<DenoCacheLocation> caches)
|
||||
{
|
||||
var result = new Dictionary<NpmPackageKey, NpmPackageInfo>();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var root = TryResolvePackageRoot(caches, key.Name, key.Version);
|
||||
result[key] = new NpmPackageInfo(key.Name, key.Version, root);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? TryResolvePackageRoot(IEnumerable<DenoCacheLocation> caches, string packageName, string version)
|
||||
{
|
||||
foreach (var cache in caches)
|
||||
{
|
||||
if (cache.Kind is not (DenoCacheLocationKind.Env or DenoCacheLocationKind.Workspace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var registryRoot = Path.Combine(cache.AbsolutePath, "npm", "registry.npmjs.org");
|
||||
var packagePath = CombineRegistryPath(registryRoot, packageName, version);
|
||||
|
||||
if (packagePath is not null && Directory.Exists(packagePath))
|
||||
{
|
||||
return packagePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? CombineRegistryPath(string registryRoot, string packageName, string version)
|
||||
{
|
||||
var segments = packageName.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var current = registryRoot;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
}
|
||||
|
||||
return Path.Combine(current, version);
|
||||
}
|
||||
|
||||
private static bool TryParseNpmSpecifier(
|
||||
string specifier,
|
||||
out string packageName,
|
||||
out string requestedVersion,
|
||||
out string subpath,
|
||||
out string raw)
|
||||
{
|
||||
packageName = string.Empty;
|
||||
requestedVersion = string.Empty;
|
||||
subpath = string.Empty;
|
||||
raw = specifier;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(specifier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = specifier.StartsWith("npm:", StringComparison.OrdinalIgnoreCase)
|
||||
? specifier[4..]
|
||||
: specifier;
|
||||
|
||||
var lastAt = trimmed.LastIndexOf('@');
|
||||
if (lastAt <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
packageName = trimmed[..lastAt];
|
||||
var remainder = trimmed[(lastAt + 1)..];
|
||||
|
||||
var slashIndex = remainder.IndexOf('/');
|
||||
if (slashIndex >= 0)
|
||||
{
|
||||
requestedVersion = remainder[..slashIndex];
|
||||
subpath = remainder[(slashIndex + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
requestedVersion = remainder;
|
||||
subpath = string.Empty;
|
||||
}
|
||||
|
||||
return packageName.Length > 0 && requestedVersion.Length > 0;
|
||||
}
|
||||
|
||||
private static bool TryParseLockSpecifier(string value, out string name, out string version)
|
||||
{
|
||||
name = string.Empty;
|
||||
version = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastAt = value.LastIndexOf('@');
|
||||
if (lastAt <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
name = value[..lastAt];
|
||||
version = value[(lastAt + 1)..];
|
||||
return name.Length > 0 && version.Length > 0;
|
||||
}
|
||||
|
||||
private static (ImmutableArray<DenoDynamicImportObservation> DynamicImports, ImmutableArray<DenoLiteralFetchObservation> LiteralFetches) AnalyzeSourceFiles(
|
||||
DenoWorkspace workspace,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dynamicBuilder = ImmutableArray.CreateBuilder<DenoDynamicImportObservation>();
|
||||
var fetchBuilder = ImmutableArray.CreateBuilder<DenoLiteralFetchObservation>();
|
||||
|
||||
foreach (var file in workspace.FileSystem.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (file.Source is not DenoVirtualFileSource.Workspace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!SourceFileExtensions.Contains(Path.GetExtension(file.AbsolutePath)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(file.AbsolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lineNumber = 0;
|
||||
using var stream = new StreamReader(file.AbsolutePath);
|
||||
string? line;
|
||||
while ((line = stream.ReadLine()) is not null)
|
||||
{
|
||||
lineNumber++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (Match match in DynamicImportRegex.Matches(line))
|
||||
{
|
||||
var specifier = match.Groups["url"].Value;
|
||||
if (string.IsNullOrWhiteSpace(specifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamicBuilder.Add(new DenoDynamicImportObservation(
|
||||
file.AbsolutePath,
|
||||
lineNumber,
|
||||
specifier,
|
||||
"network.dynamic_import.literal"));
|
||||
}
|
||||
|
||||
foreach (Match match in LiteralFetchRegex.Matches(line))
|
||||
{
|
||||
var url = match.Groups["url"].Value;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchBuilder.Add(new DenoLiteralFetchObservation(
|
||||
file.AbsolutePath,
|
||||
lineNumber,
|
||||
url,
|
||||
"network.fetch.literal"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (dynamicBuilder.ToImmutable(), fetchBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoCapabilityRecord> BuildCapabilities(
|
||||
ImmutableArray<DenoBuiltinUsage> builtinUsages,
|
||||
DenoModuleGraph graph,
|
||||
ImmutableArray<DenoDynamicImportObservation> dynamicImports,
|
||||
ImmutableArray<DenoLiteralFetchObservation> literalFetches)
|
||||
{
|
||||
var capabilityMap = new Dictionary<(DenoCapabilityType Capability, string Reason), HashSet<string>>();
|
||||
|
||||
void AddCapability(DenoCapabilityType capability, string reasonCode, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
source = "(unknown)";
|
||||
}
|
||||
|
||||
var key = (capability, reasonCode);
|
||||
if (!capabilityMap.TryGetValue(key, out var set))
|
||||
{
|
||||
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
capabilityMap[key] = set;
|
||||
}
|
||||
|
||||
set.Add(source);
|
||||
}
|
||||
|
||||
foreach (var usage in builtinUsages)
|
||||
{
|
||||
foreach (var entry in ResolveCapabilityEntries(usage.Specifier))
|
||||
{
|
||||
AddCapability(entry.Capability, entry.ReasonCode, usage.Specifier);
|
||||
}
|
||||
}
|
||||
|
||||
var remoteSpecifiers = graph.Edges
|
||||
.Where(edge => edge.Specifier.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
edge.Specifier.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(edge => edge.Specifier)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var specifier in remoteSpecifiers)
|
||||
{
|
||||
AddCapability(DenoCapabilityType.Network, "network.remote_module_import", specifier);
|
||||
}
|
||||
|
||||
foreach (var observation in dynamicImports)
|
||||
{
|
||||
AddCapability(DenoCapabilityType.Network, observation.ReasonCode, observation.Specifier);
|
||||
}
|
||||
|
||||
foreach (var observation in literalFetches)
|
||||
{
|
||||
AddCapability(DenoCapabilityType.Network, observation.ReasonCode, observation.Url);
|
||||
}
|
||||
|
||||
return capabilityMap
|
||||
.OrderBy(entry => entry.Key.Capability)
|
||||
.ThenBy(entry => entry.Key.Reason, StringComparer.Ordinal)
|
||||
.Select(entry => new DenoCapabilityRecord(
|
||||
entry.Key.Capability,
|
||||
entry.Key.Reason,
|
||||
entry.Value
|
||||
.OrderBy(source => source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray()))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<(DenoCapabilityType Capability, string ReasonCode)> ResolveCapabilityEntries(string specifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(specifier))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var signature in BuiltinCapabilitySignatures)
|
||||
{
|
||||
if (!specifier.StartsWith(signature.Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in signature.Entries)
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePath(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Replace('\\', '/');
|
||||
return trimmed.StartsWith("./", StringComparison.Ordinal) || trimmed.StartsWith("/", StringComparison.Ordinal)
|
||||
? trimmed.TrimStart('.')
|
||||
: $"./{trimmed}";
|
||||
}
|
||||
|
||||
private readonly record struct NpmPackageKey(string Name, string Version);
|
||||
|
||||
private sealed class NpmPackageInfo : IDisposable
|
||||
{
|
||||
private readonly JsonDocument? _manifest;
|
||||
|
||||
public NpmPackageInfo(string name, string version, string? rootPath)
|
||||
{
|
||||
Name = name;
|
||||
Version = version;
|
||||
RootPath = rootPath;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
var manifestPath = Path.Combine(rootPath!, "package.json");
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
PackageJsonPath = manifestPath;
|
||||
_manifest = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public string? RootPath { get; }
|
||||
|
||||
public string? PackageJsonPath { get; }
|
||||
|
||||
public (string? Target, string? Condition) ResolveExport(string subpath)
|
||||
{
|
||||
if (_manifest is null)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var root = _manifest.RootElement;
|
||||
var normalizedKey = NormalizeExportKey(subpath);
|
||||
|
||||
if (root.TryGetProperty("exports", out var exports))
|
||||
{
|
||||
var (target, condition) = ResolveExportsEntry(exports, normalizedKey);
|
||||
if (target is not null)
|
||||
{
|
||||
return (target, condition);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(subpath))
|
||||
{
|
||||
if (root.TryGetProperty("module", out var moduleElement) && moduleElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return (moduleElement.GetString(), "module");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("main", out var mainElement) && mainElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return (mainElement.GetString(), "main");
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_manifest?.Dispose();
|
||||
}
|
||||
|
||||
private static string NormalizeExportKey(string subpath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subpath))
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (subpath.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
return subpath;
|
||||
}
|
||||
|
||||
if (subpath.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return $".{subpath}";
|
||||
}
|
||||
|
||||
return $"./{subpath}";
|
||||
}
|
||||
|
||||
private static (string? Target, string? Condition) ResolveExportsEntry(JsonElement exports, string lookupKey)
|
||||
{
|
||||
return exports.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => (exports.GetString(), null),
|
||||
JsonValueKind.Object => ResolveObject(exports, lookupKey),
|
||||
JsonValueKind.Array => ResolveArray(exports, lookupKey),
|
||||
_ => (null, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? Target, string? Condition) ResolveObject(JsonElement element, string lookupKey)
|
||||
{
|
||||
if (element.TryGetProperty(lookupKey, out var entry))
|
||||
{
|
||||
var result = ResolveEntry(entry);
|
||||
if (result.Target is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (lookupKey != "." && element.TryGetProperty(".", out var rootEntry))
|
||||
{
|
||||
var result = ResolveEntry(rootEntry);
|
||||
if (result.Target is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Contains('*', StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pattern = property.Name;
|
||||
var starIndex = pattern.IndexOf('*');
|
||||
var prefix = pattern[..starIndex];
|
||||
var suffix = pattern[(starIndex + 1)..];
|
||||
|
||||
if (lookupKey.StartsWith(prefix, StringComparison.Ordinal) &&
|
||||
lookupKey.EndsWith(suffix, StringComparison.Ordinal) &&
|
||||
lookupKey.Length >= prefix.Length + suffix.Length)
|
||||
{
|
||||
var matched = lookupKey[prefix.Length..(lookupKey.Length - suffix.Length)];
|
||||
var (target, condition) = ResolveEntry(property.Value);
|
||||
if (target is not null)
|
||||
{
|
||||
var substituted = target.Replace("*", matched, StringComparison.Ordinal);
|
||||
return (substituted, condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static (string? Target, string? Condition) ResolveArray(JsonElement element, string lookupKey)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var result = ResolveExportsEntry(item, lookupKey);
|
||||
if (result.Target is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static (string? Target, string? Condition) ResolveEntry(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => (element.GetString(), null),
|
||||
JsonValueKind.Array => ResolveArray(element, "."),
|
||||
JsonValueKind.Object => ResolveConditionalObject(element),
|
||||
_ => (null, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? Target, string? Condition) ResolveConditionalObject(JsonElement element)
|
||||
{
|
||||
foreach (var condition in ConditionPriority)
|
||||
{
|
||||
if (element.TryGetProperty(condition, out var conditionalValue))
|
||||
{
|
||||
var result = ResolveEntry(conditionalValue);
|
||||
if (result.Target is not null)
|
||||
{
|
||||
return (result.Target, result.Condition ?? condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var result = ResolveEntry(property.Value);
|
||||
if (result.Target is not null)
|
||||
{
|
||||
return (result.Target, result.Condition ?? property.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed record DenoNpmResolution(
|
||||
string Specifier,
|
||||
string PackageName,
|
||||
string PackageVersion,
|
||||
string SourceNodeId,
|
||||
string? Condition,
|
||||
string? Target,
|
||||
string? ResolvedPath,
|
||||
bool ExistsOnDisk);
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoPathUtilities
|
||||
{
|
||||
public static string NormalizeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = path.Replace('\\', '/');
|
||||
while (normalized.Contains("//", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace("//", "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return normalized.Trim('/');
|
||||
}
|
||||
|
||||
public static string ResolvePath(string root, string candidate)
|
||||
{
|
||||
var value = ExpandHome(candidate);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (Path.IsPathFullyQualified(value))
|
||||
{
|
||||
return Path.GetFullPath(value);
|
||||
}
|
||||
|
||||
var combined = Path.Combine(root, value);
|
||||
return Path.GetFullPath(combined);
|
||||
}
|
||||
|
||||
public static string CreateAlias(string absolutePath, string? fallback = null)
|
||||
{
|
||||
var directory = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var name = Path.GetFileName(directory);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = fallback ?? "root";
|
||||
}
|
||||
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(directory));
|
||||
var shortHash = Convert.ToHexString(hashBytes.AsSpan(0, 6)).ToLowerInvariant();
|
||||
return $"{name}-{shortHash}";
|
||||
}
|
||||
|
||||
private static string ExpandHome(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (value[0] != '~')
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var home = Environment.GetEnvironmentVariable("HOME");
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
return value; // Unable to expand; return original.
|
||||
}
|
||||
|
||||
var remainder = value.Length > 1 && (value[1] == '/' || value[1] == '\\')
|
||||
? value[2..]
|
||||
: value[1..];
|
||||
|
||||
return Path.Combine(home, remainder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoVendorDirectory
|
||||
{
|
||||
public DenoVendorDirectory(
|
||||
string absolutePath,
|
||||
string relativePath,
|
||||
string alias,
|
||||
string? layerDigest,
|
||||
DenoImportMapDocument? importMap,
|
||||
DenoLockFile? lockFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
throw new ArgumentException("Path is required", nameof(absolutePath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new ArgumentException("Relative path is required", nameof(relativePath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
throw new ArgumentException("Alias is required", nameof(alias));
|
||||
}
|
||||
|
||||
AbsolutePath = Path.GetFullPath(absolutePath);
|
||||
RelativePath = DenoPathUtilities.NormalizeRelativePath(relativePath);
|
||||
Alias = alias;
|
||||
LayerDigest = layerDigest;
|
||||
ImportMap = importMap;
|
||||
LockFile = lockFile;
|
||||
}
|
||||
|
||||
public string AbsolutePath { get; }
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public string Alias { get; }
|
||||
|
||||
public string? LayerDigest { get; }
|
||||
|
||||
public DenoImportMapDocument? ImportMap { get; }
|
||||
|
||||
public DenoLockFile? LockFile { get; }
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal enum DenoVirtualFileSource
|
||||
{
|
||||
Workspace,
|
||||
ImportMap,
|
||||
LockFile,
|
||||
Vendor,
|
||||
DenoDir,
|
||||
Layer,
|
||||
}
|
||||
|
||||
internal sealed record DenoVirtualFile(
|
||||
string VirtualPath,
|
||||
string AbsolutePath,
|
||||
DenoVirtualFileSource Source,
|
||||
string? LayerDigest,
|
||||
long Length,
|
||||
DateTimeOffset? LastWriteTimeUtc);
|
||||
|
||||
internal sealed class DenoVirtualFileSystem
|
||||
{
|
||||
private readonly ImmutableArray<DenoVirtualFile> _files;
|
||||
private readonly ImmutableDictionary<string, ImmutableArray<DenoVirtualFile>> _filesByPath;
|
||||
|
||||
private DenoVirtualFileSystem(IEnumerable<DenoVirtualFile> files)
|
||||
{
|
||||
var ordered = files
|
||||
.Where(static file => file is not null)
|
||||
.OrderBy(static file => file.VirtualPath, StringComparer.Ordinal)
|
||||
.ThenBy(static file => file.Source)
|
||||
.ThenBy(static file => file.AbsolutePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
_files = ordered;
|
||||
_filesByPath = ordered
|
||||
.GroupBy(static file => file.VirtualPath, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(
|
||||
static group => group.Key,
|
||||
static group => group.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public ImmutableArray<DenoVirtualFile> Files => _files;
|
||||
|
||||
public IEnumerable<DenoVirtualFile> EnumerateBySource(DenoVirtualFileSource source)
|
||||
=> _files.Where(file => file.Source == source);
|
||||
|
||||
public bool TryGetLatest(string virtualPath, out DenoVirtualFile? file)
|
||||
{
|
||||
if (_filesByPath.TryGetValue(virtualPath, out var items) && items.Length > 0)
|
||||
{
|
||||
file = items[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
file = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static DenoVirtualFileSystem Build(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
IEnumerable<DenoImportMapDocument> importMaps,
|
||||
IEnumerable<DenoLockFile> lockFiles,
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
IEnumerable<DenoCacheLocation> cacheLocations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var files = new List<DenoVirtualFile>();
|
||||
AddConfigFiles(context, configs, files, cancellationToken);
|
||||
AddImportMaps(importMaps, files, cancellationToken);
|
||||
AddLockFiles(lockFiles, files, cancellationToken);
|
||||
AddVendorFiles(vendors, files, cancellationToken);
|
||||
AddCacheFiles(cacheLocations, files, cancellationToken);
|
||||
|
||||
return new DenoVirtualFileSystem(files);
|
||||
}
|
||||
|
||||
private static void AddConfigFiles(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var config in configs ?? Array.Empty<DenoConfigDocument>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (File.Exists(config.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
config.AbsolutePath,
|
||||
context.GetRelativePath(config.AbsolutePath),
|
||||
DenoVirtualFileSource.Workspace,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.AbsolutePath)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ImportMapPath) && File.Exists(config.ImportMapPath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
config.ImportMapPath!,
|
||||
context.GetRelativePath(config.ImportMapPath!),
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.ImportMapPath!)));
|
||||
}
|
||||
|
||||
if (config.LockEnabled && !string.IsNullOrWhiteSpace(config.LockFilePath) && File.Exists(config.LockFilePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
config.LockFilePath!,
|
||||
context.GetRelativePath(config.LockFilePath!),
|
||||
DenoVirtualFileSource.LockFile,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.LockFilePath!)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddImportMaps(IEnumerable<DenoImportMapDocument> maps, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var map in maps ?? Array.Empty<DenoImportMapDocument>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (map.IsInline || string.IsNullOrWhiteSpace(map.AbsolutePath) || !File.Exists(map.AbsolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(CreateVirtualFile(
|
||||
map.AbsolutePath,
|
||||
map.Origin,
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(map.AbsolutePath)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddLockFiles(IEnumerable<DenoLockFile> lockFiles, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var lockFile in lockFiles ?? Array.Empty<DenoLockFile>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(lockFile.AbsolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(CreateVirtualFile(
|
||||
lockFile.AbsolutePath,
|
||||
lockFile.RelativePath,
|
||||
DenoVirtualFileSource.LockFile,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(lockFile.AbsolutePath)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddVendorFiles(IEnumerable<DenoVendorDirectory> vendors, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var vendor in vendors ?? Array.Empty<DenoVendorDirectory>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!Directory.Exists(vendor.AbsolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in SafeEnumerateFiles(vendor.AbsolutePath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
files.Add(CreateVirtualFile(
|
||||
file,
|
||||
$"vendor://{vendor.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(vendor.AbsolutePath, file))}",
|
||||
DenoVirtualFileSource.Vendor,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file)));
|
||||
}
|
||||
|
||||
if (vendor.ImportMap is { AbsolutePath: not null } importMapFile && File.Exists(importMapFile.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
importMapFile.AbsolutePath,
|
||||
$"vendor://{vendor.Alias}/import_map.json",
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(importMapFile.AbsolutePath)));
|
||||
}
|
||||
|
||||
if (vendor.LockFile is { AbsolutePath: not null } vendorLock && File.Exists(vendorLock.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
vendorLock.AbsolutePath,
|
||||
$"vendor://{vendor.Alias}/deno.lock",
|
||||
DenoVirtualFileSource.LockFile,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(vendorLock.AbsolutePath)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCacheFiles(IEnumerable<DenoCacheLocation> cacheLocations, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var cache in cacheLocations ?? Array.Empty<DenoCacheLocation>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!Directory.Exists(cache.AbsolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in SafeEnumerateFiles(cache.AbsolutePath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
files.Add(CreateVirtualFile(
|
||||
file,
|
||||
$"deno-dir://{cache.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(cache.AbsolutePath, file))}",
|
||||
cache.Kind == DenoCacheLocationKind.Layer ? DenoVirtualFileSource.Layer : DenoVirtualFileSource.DenoDir,
|
||||
cache.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateFiles(string root)
|
||||
{
|
||||
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 DenoVirtualFile CreateVirtualFile(
|
||||
string absolutePath,
|
||||
string virtualPath,
|
||||
DenoVirtualFileSource source,
|
||||
string? layerDigest)
|
||||
{
|
||||
var info = new FileInfo(absolutePath);
|
||||
var lastWrite = info.Exists
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(info.LastWriteTimeUtc, DateTimeKind.Utc))
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
return new DenoVirtualFile(
|
||||
VirtualPath: NormalizeVirtualPath(source, virtualPath),
|
||||
AbsolutePath: info.FullName,
|
||||
Source: source,
|
||||
LayerDigest: layerDigest,
|
||||
Length: info.Exists ? info.Length : 0,
|
||||
LastWriteTimeUtc: lastWrite);
|
||||
}
|
||||
|
||||
private static string NormalizeVirtualPath(DenoVirtualFileSource source, string relativePath)
|
||||
{
|
||||
var normalized = DenoPathUtilities.NormalizeRelativePath(relativePath);
|
||||
if (normalized.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var prefix = source switch
|
||||
{
|
||||
DenoVirtualFileSource.Workspace => "workspace",
|
||||
DenoVirtualFileSource.ImportMap => "import-map",
|
||||
DenoVirtualFileSource.LockFile => "lock",
|
||||
DenoVirtualFileSource.Vendor => "vendor",
|
||||
DenoVirtualFileSource.DenoDir => "deno-dir",
|
||||
DenoVirtualFileSource.Layer => "layer",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
return $"{prefix}://{normalized}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoWorkspace
|
||||
{
|
||||
public DenoWorkspace(
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
IEnumerable<DenoImportMapDocument> importMaps,
|
||||
IEnumerable<DenoLockFile> lockFiles,
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
IEnumerable<DenoCacheLocation> cacheLocations,
|
||||
DenoVirtualFileSystem fileSystem)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configs);
|
||||
ArgumentNullException.ThrowIfNull(importMaps);
|
||||
ArgumentNullException.ThrowIfNull(lockFiles);
|
||||
ArgumentNullException.ThrowIfNull(vendors);
|
||||
ArgumentNullException.ThrowIfNull(cacheLocations);
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
|
||||
Configurations = configs
|
||||
.Where(static config => config is not null)
|
||||
.OrderBy(static config => config.RelativePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
ImportMaps = importMaps
|
||||
.Where(static map => map is not null)
|
||||
.OrderBy(static map => map.SortKey, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
LockFiles = lockFiles
|
||||
.Where(static file => file is not null)
|
||||
.OrderBy(static file => file.RelativePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
Vendors = vendors
|
||||
.Where(static vendor => vendor is not null)
|
||||
.OrderBy(static vendor => vendor.RelativePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
CacheLocations = cacheLocations
|
||||
.Where(static cache => cache is not null)
|
||||
.OrderBy(static cache => cache.Alias, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
FileSystem = fileSystem;
|
||||
}
|
||||
|
||||
public ImmutableArray<DenoConfigDocument> Configurations { get; }
|
||||
|
||||
public ImmutableArray<DenoImportMapDocument> ImportMaps { get; }
|
||||
|
||||
public ImmutableArray<DenoLockFile> LockFiles { get; }
|
||||
|
||||
public ImmutableArray<DenoVendorDirectory> Vendors { get; }
|
||||
|
||||
public ImmutableArray<DenoCacheLocation> CacheLocations { get; }
|
||||
|
||||
public DenoVirtualFileSystem FileSystem { get; }
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal static class DenoWorkspaceNormalizer
|
||||
{
|
||||
private static readonly EnumerationOptions RecursiveEnumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
ReturnSpecialDirectories = false,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
private static readonly string[] ConfigPatterns = { "deno.json", "deno.jsonc" };
|
||||
private static readonly string[] VendorDirectoryNames = { "vendor" };
|
||||
private static readonly string[] DefaultDenoDirCandidates =
|
||||
{
|
||||
".deno",
|
||||
".cache/deno",
|
||||
"deno-dir",
|
||||
"deno_dir",
|
||||
"deno",
|
||||
"var/cache/deno",
|
||||
"usr/local/share/deno",
|
||||
"opt/deno",
|
||||
};
|
||||
|
||||
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
|
||||
private static readonly string[] LayerDenoDirNames = { ".deno", ".cache/deno", "deno-dir", "deno_dir", "deno" };
|
||||
|
||||
public static ValueTask<DenoWorkspace> NormalizeAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var configs = DiscoverConfigs(context, cancellationToken);
|
||||
var importMaps = CollectImportMaps(context, configs, cancellationToken);
|
||||
var lockFiles = CollectLockFiles(context, configs, cancellationToken);
|
||||
var vendors = DiscoverVendors(context, configs, cancellationToken);
|
||||
var cacheLocations = DiscoverCacheLocations(context, vendors, cancellationToken);
|
||||
|
||||
var combinedImportMaps = importMaps
|
||||
.Concat(vendors.Select(v => v.ImportMap).Where(static map => map is not null)!.Cast<DenoImportMapDocument>())
|
||||
.Where(static map => map is not null)
|
||||
.GroupBy(static map => (map!.AbsolutePath ?? map.SortKey), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static group => group.First()!)
|
||||
.ToImmutableArray();
|
||||
|
||||
var combinedLockFiles = lockFiles
|
||||
.Concat(vendors.Select(v => v.LockFile).Where(static file => file is not null)!.Cast<DenoLockFile>())
|
||||
.GroupBy(static file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static group => group.First())
|
||||
.ToImmutableArray();
|
||||
|
||||
var fileSystem = DenoVirtualFileSystem.Build(
|
||||
context,
|
||||
configs,
|
||||
combinedImportMaps,
|
||||
combinedLockFiles,
|
||||
vendors,
|
||||
cacheLocations,
|
||||
cancellationToken);
|
||||
|
||||
var workspace = new DenoWorkspace(
|
||||
configs,
|
||||
combinedImportMaps,
|
||||
combinedLockFiles,
|
||||
vendors,
|
||||
cacheLocations,
|
||||
fileSystem);
|
||||
|
||||
return ValueTask.FromResult(workspace);
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoConfigDocument> DiscoverConfigs(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<DenoConfigDocument>();
|
||||
foreach (var pattern in ConfigPatterns)
|
||||
{
|
||||
foreach (var path in SafeEnumerateFiles(context.RootPath, pattern))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relative = context.GetRelativePath(path);
|
||||
if (DenoConfigDocument.TryLoad(path, relative, cancellationToken, out var config) && config is not null)
|
||||
{
|
||||
results.Add(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(static config => config.RelativePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoImportMapDocument> CollectImportMaps(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoImportMapDocument>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var config in configs ?? Array.Empty<DenoConfigDocument>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (config.InlineImportMap is not null && seen.Add($"inline::{config.RelativePath}"))
|
||||
{
|
||||
builder.Add(config.InlineImportMap);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ImportMapPath) &&
|
||||
File.Exists(config.ImportMapPath) &&
|
||||
seen.Add(Path.GetFullPath(config.ImportMapPath!)))
|
||||
{
|
||||
var origin = context.GetRelativePath(config.ImportMapPath!);
|
||||
if (DenoImportMapDocument.TryLoadFromFile(config.ImportMapPath!, origin, out var document) && document is not null)
|
||||
{
|
||||
builder.Add(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoLockFile> CollectLockFiles(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoLockFile>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var config in configs ?? Array.Empty<DenoConfigDocument>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!config.LockEnabled || string.IsNullOrWhiteSpace(config.LockFilePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var absolute = Path.GetFullPath(config.LockFilePath);
|
||||
if (!File.Exists(absolute) || !seen.Add(absolute))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DenoLockFile.TryLoad(absolute, context.GetRelativePath(absolute), out var lockFile) && lockFile is not null)
|
||||
{
|
||||
builder.Add(lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var path in SafeEnumerateFiles(context.RootPath, "deno.lock"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var absolute = Path.GetFullPath(path);
|
||||
if (!seen.Add(absolute))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DenoLockFile.TryLoad(absolute, context.GetRelativePath(absolute), out var lockFile) && lockFile is not null)
|
||||
{
|
||||
builder.Add(lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoVendorDirectory> DiscoverVendors(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoVendorDirectory>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void TryAddVendor(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var absolute = Path.GetFullPath(path);
|
||||
if (!Directory.Exists(absolute) || !seen.Add(absolute))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relative = context.GetRelativePath(absolute);
|
||||
var alias = DenoPathUtilities.CreateAlias(absolute, "vendor");
|
||||
var layerDigest = DenoLayerMetadata.TryExtractDigest(absolute);
|
||||
|
||||
DenoImportMapDocument? importMap = null;
|
||||
var importMapPath = Path.Combine(absolute, "import_map.json");
|
||||
if (File.Exists(importMapPath))
|
||||
{
|
||||
DenoImportMapDocument.TryLoadFromFile(importMapPath, $"vendor://{alias}/import_map.json", out importMap);
|
||||
}
|
||||
|
||||
DenoLockFile? lockFile = null;
|
||||
var lockPath = Path.Combine(absolute, "deno.lock");
|
||||
if (File.Exists(lockPath))
|
||||
{
|
||||
DenoLockFile.TryLoad(lockPath, $"vendor://{alias}/deno.lock", out lockFile);
|
||||
}
|
||||
|
||||
builder.Add(new DenoVendorDirectory(
|
||||
absolute,
|
||||
relative,
|
||||
alias,
|
||||
layerDigest,
|
||||
importMap,
|
||||
lockFile));
|
||||
}
|
||||
|
||||
foreach (var config in configs ?? Array.Empty<DenoConfigDocument>())
|
||||
{
|
||||
if (config.VendorEnabled && !string.IsNullOrWhiteSpace(config.VendorDirectoryPath))
|
||||
{
|
||||
TryAddVendor(config.VendorDirectoryPath!);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var name in VendorDirectoryNames)
|
||||
{
|
||||
foreach (var path in SafeEnumerateDirectories(context.RootPath, name))
|
||||
{
|
||||
TryAddVendor(path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (layerRoot, _) in EnumerateLayerRoots(context.RootPath))
|
||||
{
|
||||
foreach (var name in VendorDirectoryNames)
|
||||
{
|
||||
var candidate = Path.Combine(layerRoot, name);
|
||||
TryAddVendor(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<DenoCacheLocation> DiscoverCacheLocations(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoCacheLocation>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void TryAdd(string path, DenoCacheLocationKind kind, string? layerDigest = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var absolute = Path.GetFullPath(path);
|
||||
if (!Directory.Exists(absolute) || !IsLikelyDenoDir(absolute) || !seen.Add(absolute))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var alias = DenoPathUtilities.CreateAlias(absolute, "deno");
|
||||
builder.Add(new DenoCacheLocation(
|
||||
absolute,
|
||||
alias,
|
||||
kind,
|
||||
layerDigest ?? DenoLayerMetadata.TryExtractDigest(absolute)));
|
||||
}
|
||||
|
||||
var envDir = Environment.GetEnvironmentVariable("DENO_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(envDir))
|
||||
{
|
||||
TryAdd(DenoPathUtilities.ResolvePath(context.RootPath, envDir), DenoCacheLocationKind.Env);
|
||||
}
|
||||
|
||||
foreach (var candidate in DefaultDenoDirCandidates)
|
||||
{
|
||||
TryAdd(Path.Combine(context.RootPath, candidate), DenoCacheLocationKind.Workspace);
|
||||
}
|
||||
|
||||
DiscoverHomeDirectories(Path.Combine(context.RootPath, "home"), TryAdd);
|
||||
DiscoverHomeDirectories(Path.Combine(context.RootPath, "Users"), TryAdd);
|
||||
|
||||
foreach (var vendor in vendors ?? Array.Empty<DenoVendorDirectory>())
|
||||
{
|
||||
var sibling = Path.Combine(Path.GetDirectoryName(vendor.AbsolutePath) ?? context.RootPath, ".deno");
|
||||
TryAdd(sibling, vendor.LayerDigest is null ? DenoCacheLocationKind.Workspace : DenoCacheLocationKind.Layer, vendor.LayerDigest);
|
||||
}
|
||||
|
||||
foreach (var (layerRoot, digest) in EnumerateLayerRoots(context.RootPath))
|
||||
{
|
||||
foreach (var name in LayerDenoDirNames)
|
||||
{
|
||||
var candidate = Path.Combine(layerRoot, name);
|
||||
TryAdd(candidate, DenoCacheLocationKind.Layer, digest);
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
.OrderBy(static cache => cache.Alias, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateFiles(string root, string pattern)
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IEnumerable<string> iterator;
|
||||
try
|
||||
{
|
||||
iterator = Directory.EnumerateFiles(root, pattern, RecursiveEnumeration);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var path in iterator)
|
||||
{
|
||||
yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateDirectories(string root, string pattern)
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IEnumerable<string> iterator;
|
||||
try
|
||||
{
|
||||
iterator = Directory.EnumerateDirectories(root, pattern, RecursiveEnumeration);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var path in iterator)
|
||||
{
|
||||
yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string RootPath, string? Digest)> EnumerateLayerRoots(string workspaceRoot)
|
||||
{
|
||||
foreach (var candidate in LayerRootCandidates)
|
||||
{
|
||||
var root = Path.Combine(workspaceRoot, candidate);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string> iterator;
|
||||
try
|
||||
{
|
||||
iterator = Directory.EnumerateDirectories(root);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var layerDirectory in iterator)
|
||||
{
|
||||
var digest = DenoLayerMetadata.TryExtractDigest(layerDirectory);
|
||||
var fsDirectory = Path.Combine(layerDirectory, "fs");
|
||||
yield return Directory.Exists(fsDirectory)
|
||||
? (fsDirectory, digest)
|
||||
: (layerDirectory, digest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DiscoverHomeDirectories(string homeRoot, Action<string, DenoCacheLocationKind, string?> add)
|
||||
{
|
||||
if (!Directory.Exists(homeRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<string> iterator;
|
||||
try
|
||||
{
|
||||
iterator = Directory.EnumerateDirectories(homeRoot);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var home in iterator)
|
||||
{
|
||||
var cache = Path.Combine(home, ".cache", "deno");
|
||||
add(cache, DenoCacheLocationKind.Home, DenoLayerMetadata.TryExtractDigest(cache));
|
||||
|
||||
var dotDeno = Path.Combine(home, ".deno");
|
||||
add(dotDeno, DenoCacheLocationKind.Home, DenoLayerMetadata.TryExtractDigest(dotDeno));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyDenoDir(string path)
|
||||
{
|
||||
var deps = Path.Combine(path, "deps");
|
||||
var gen = Path.Combine(path, "gen");
|
||||
var npm = Path.Combine(path, "npm");
|
||||
return Directory.Exists(deps) || Directory.Exists(gen) || Directory.Exists(npm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
|
||||
internal static class DenoObservationBuilder
|
||||
{
|
||||
public static DenoObservationDocument Build(
|
||||
DenoModuleGraph moduleGraph,
|
||||
DenoCompatibilityAnalysis compatibility,
|
||||
ImmutableArray<DenoBundleObservation> bundles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(moduleGraph);
|
||||
ArgumentNullException.ThrowIfNull(compatibility);
|
||||
|
||||
var entrypoints = ExtractEntrypoints(moduleGraph, bundles);
|
||||
var moduleSpecifiers = ExtractModuleSpecifiers(moduleGraph);
|
||||
var bundleSummaries = bundles
|
||||
.Select(bundle => new DenoObservationBundleSummary(
|
||||
bundle.SourcePath,
|
||||
bundle.BundleType,
|
||||
bundle.Entrypoint,
|
||||
bundle.Modules.Length,
|
||||
bundle.Resources.Length))
|
||||
.OrderBy(summary => summary.SourcePath, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new DenoObservationDocument(
|
||||
entrypoints,
|
||||
moduleSpecifiers,
|
||||
compatibility.Capabilities,
|
||||
compatibility.DynamicImports,
|
||||
compatibility.LiteralFetches,
|
||||
bundleSummaries);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractEntrypoints(
|
||||
DenoModuleGraph moduleGraph,
|
||||
ImmutableArray<DenoBundleObservation> bundles)
|
||||
{
|
||||
var entrypoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var node in moduleGraph.Nodes)
|
||||
{
|
||||
if (node.Kind == DenoModuleKind.WorkspaceConfig &&
|
||||
node.Metadata.TryGetValue("entry", out var entry) &&
|
||||
!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
entrypoints.Add(entry!);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bundle in bundles)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(bundle.Entrypoint))
|
||||
{
|
||||
entrypoints.Add(bundle.Entrypoint!);
|
||||
}
|
||||
}
|
||||
|
||||
return entrypoints
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractModuleSpecifiers(DenoModuleGraph moduleGraph)
|
||||
{
|
||||
return moduleGraph.Nodes
|
||||
.Where(node => node.Kind == DenoModuleKind.RemoteModule || node.Kind == DenoModuleKind.WorkspaceModule)
|
||||
.Select(node => node.DisplayName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToImmutableArray()!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
|
||||
internal sealed record DenoObservationBundleSummary(
|
||||
string SourcePath,
|
||||
string BundleType,
|
||||
string? Entrypoint,
|
||||
int ModuleCount,
|
||||
int ResourceCount);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
|
||||
internal sealed record DenoObservationDocument(
|
||||
ImmutableArray<string> Entrypoints,
|
||||
ImmutableArray<string> ModuleSpecifiers,
|
||||
ImmutableArray<DenoCapabilityRecord> Capabilities,
|
||||
ImmutableArray<DenoDynamicImportObservation> DynamicImports,
|
||||
ImmutableArray<DenoLiteralFetchObservation> LiteralFetches,
|
||||
ImmutableArray<DenoObservationBundleSummary> Bundles);
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal.Observations;
|
||||
|
||||
internal static class DenoObservationSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize(DenoObservationDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
using var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
WriteArray(writer, "entrypoints", document.Entrypoints);
|
||||
WriteArray(writer, "modules", document.ModuleSpecifiers);
|
||||
|
||||
writer.WritePropertyName("capabilities");
|
||||
writer.WriteStartArray();
|
||||
foreach (var record in document.Capabilities.OrderBy(c => c.Capability).ThenBy(c => c.ReasonCode, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("capability", record.Capability.ToString());
|
||||
writer.WriteString("reason", record.ReasonCode);
|
||||
WriteArray(writer, "sources", record.Sources);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("dynamicImports");
|
||||
writer.WriteStartArray();
|
||||
foreach (var observation in document.DynamicImports.OrderBy(obs => obs.FilePath, StringComparer.Ordinal).ThenBy(obs => obs.Line))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("file", observation.FilePath);
|
||||
writer.WriteNumber("line", observation.Line);
|
||||
writer.WriteString("specifier", observation.Specifier);
|
||||
writer.WriteString("reason", observation.ReasonCode);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("literalFetches");
|
||||
writer.WriteStartArray();
|
||||
foreach (var observation in document.LiteralFetches.OrderBy(obs => obs.FilePath, StringComparer.Ordinal).ThenBy(obs => obs.Line))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("file", observation.FilePath);
|
||||
writer.WriteNumber("line", observation.Line);
|
||||
writer.WriteString("url", observation.Url);
|
||||
writer.WriteString("reason", observation.ReasonCode);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("bundles");
|
||||
writer.WriteStartArray();
|
||||
foreach (var bundle in document.Bundles)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("path", bundle.SourcePath);
|
||||
writer.WriteString("type", bundle.BundleType);
|
||||
if (!string.IsNullOrWhiteSpace(bundle.Entrypoint))
|
||||
{
|
||||
writer.WriteString("entrypoint", bundle.Entrypoint);
|
||||
}
|
||||
|
||||
writer.WriteNumber("modules", bundle.ModuleCount);
|
||||
writer.WriteNumber("resources", bundle.ResourceCount);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
public static string ComputeSha256(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void WriteArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
|
||||
{
|
||||
writer.WritePropertyName(propertyName);
|
||||
writer.WriteStartArray();
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.analyzer.lang.deno",
|
||||
"displayName": "StellaOps Deno Analyzer",
|
||||
"version": "0.1.0",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Scanner.Analyzers.Lang.Deno.dll",
|
||||
"typeName": "StellaOps.Scanner.Analyzers.Lang.Deno.DenoAnalyzerPlugin"
|
||||
},
|
||||
"capabilities": [
|
||||
"language-analyzer",
|
||||
"deno"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.analyzer.language": "deno",
|
||||
"org.stellaops.analyzer.kind": "language",
|
||||
"org.stellaops.restart.required": "true"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user