feat: Implement Runtime Facts ingestion service and NDJSON reader
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:
master
2025-11-10 07:56:15 +02:00
parent 9df52d84aa
commit 69c59defdc
132 changed files with 19718 additions and 9334 deletions

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Deno.Tests")]

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoBuiltinUsage(
string Specifier,
string SourceNodeId,
string Provenance);

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoBundleModule(
string Specifier,
string Path,
string? MediaType,
string? Checksum);

View File

@@ -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);

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoBundleResource(
string Name,
string? MediaType,
long SizeBytes);

View File

@@ -0,0 +1,5 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoBundleScanResult(
ImmutableArray<DenoBundleInspectionResult> EszipBundles,
ImmutableArray<DenoBundleInspectionResult> CompiledBundles);

View File

@@ -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>();
}
}
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoCapabilityRecord(
DenoCapabilityType Capability,
string ReasonCode,
ImmutableArray<string> Sources);

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal enum DenoCapabilityType
{
FileSystem,
Network,
Environment,
Process,
Crypto,
Ffi,
Worker,
}

View File

@@ -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);

View File

@@ -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 };
}
}

View File

@@ -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),
};
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal enum DenoContainerSourceKind
{
Cache,
Vendor,
Bundle,
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoDynamicImportObservation(
string FilePath,
int Line,
string Specifier,
string ReasonCode);

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal enum DenoImportKind
{
Static,
Dynamic,
JsonAssertion,
WasmAssertion,
BuiltIn,
Redirect,
Cache,
Dependency,
NpmBridge,
Unknown,
}

View File

@@ -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();
}
}

View File

@@ -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');
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
internal sealed record DenoLiteralFetchObservation(
string FilePath,
int Line,
string Url,
string ReasonCode);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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,
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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}";
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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()!;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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"
}
}