feat: Implement Runtime Facts ingestion service and NDJSON reader
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added RuntimeFactsNdjsonReader for reading NDJSON formatted runtime facts. - Introduced IRuntimeFactsIngestionService interface and its implementation. - Enhanced Program.cs to register new services and endpoints for runtime facts. - Updated CallgraphIngestionService to include CAS URI in stored artifacts. - Created RuntimeFactsValidationException for validation errors during ingestion. - Added tests for RuntimeFactsIngestionService and RuntimeFactsNdjsonReader. - Implemented SignalsSealedModeMonitor for compliance checks in sealed mode. - Updated project dependencies for testing utilities.
This commit is contained in:
@@ -2,35 +2,57 @@ using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
internal sealed class NodeLockData
|
||||
{
|
||||
private static readonly NodeLockData Empty = new(new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal), new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase));
|
||||
internal sealed class NodeLockData
|
||||
{
|
||||
private const string PackageLockSource = "package-lock.json";
|
||||
private const string YarnLockSource = "yarn.lock";
|
||||
private const string PnpmLockSource = "pnpm-lock.yaml";
|
||||
|
||||
private static readonly NodeLockData Empty = new(
|
||||
new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal),
|
||||
new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase),
|
||||
Array.Empty<NodeLockEntry>());
|
||||
|
||||
private readonly Dictionary<string, NodeLockEntry> _byPath;
|
||||
private readonly Dictionary<string, NodeLockEntry> _byName;
|
||||
private readonly IReadOnlyCollection<NodeLockEntry> _declared;
|
||||
|
||||
private NodeLockData(
|
||||
Dictionary<string, NodeLockEntry> byPath,
|
||||
Dictionary<string, NodeLockEntry> byName,
|
||||
IReadOnlyCollection<NodeLockEntry> declared)
|
||||
{
|
||||
_byPath = byPath;
|
||||
_byName = byName;
|
||||
_declared = declared;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, NodeLockEntry> _byPath;
|
||||
private readonly Dictionary<string, NodeLockEntry> _byName;
|
||||
|
||||
private NodeLockData(Dictionary<string, NodeLockEntry> byPath, Dictionary<string, NodeLockEntry> byName)
|
||||
{
|
||||
_byPath = byPath;
|
||||
_byName = byName;
|
||||
}
|
||||
|
||||
public static ValueTask<NodeLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var byPath = new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal);
|
||||
var byName = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
LoadPackageLockJson(rootPath, byPath, byName, cancellationToken);
|
||||
LoadYarnLock(rootPath, byName);
|
||||
LoadPnpmLock(rootPath, byName);
|
||||
|
||||
if (byPath.Count == 0 && byName.Count == 0)
|
||||
{
|
||||
return ValueTask.FromResult(Empty);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(new NodeLockData(byPath, byName));
|
||||
}
|
||||
public IReadOnlyCollection<NodeLockEntry> DeclaredPackages => _declared;
|
||||
|
||||
public static ValueTask<NodeLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var byPath = new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal);
|
||||
var byName = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var declared = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
LoadPackageLockJson(rootPath, byPath, byName, declared, cancellationToken);
|
||||
LoadYarnLock(rootPath, byName, declared);
|
||||
LoadPnpmLock(rootPath, byName, declared);
|
||||
|
||||
if (byPath.Count == 0 && byName.Count == 0 && declared.Count == 0)
|
||||
{
|
||||
return ValueTask.FromResult(Empty);
|
||||
}
|
||||
|
||||
var declaredList = declared.Values
|
||||
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Source, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Locator ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult(new NodeLockData(byPath, byName, declaredList));
|
||||
}
|
||||
|
||||
public bool TryGet(string relativePath, string packageName, out NodeLockEntry? entry)
|
||||
{
|
||||
@@ -55,16 +77,30 @@ internal sealed class NodeLockData
|
||||
return false;
|
||||
}
|
||||
|
||||
private static NodeLockEntry? CreateEntry(JsonElement element)
|
||||
{
|
||||
string? version = null;
|
||||
string? resolved = null;
|
||||
string? integrity = null;
|
||||
|
||||
if (element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
version = versionElement.GetString();
|
||||
}
|
||||
private static NodeLockEntry? CreateEntry(
|
||||
string source,
|
||||
string? locator,
|
||||
string? inferredName,
|
||||
JsonElement element)
|
||||
{
|
||||
string? name = inferredName;
|
||||
string? version = null;
|
||||
string? resolved = null;
|
||||
string? integrity = null;
|
||||
|
||||
if (element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var explicitName = nameElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(explicitName))
|
||||
{
|
||||
name = explicitName;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
version = versionElement.GetString();
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("resolved", out var resolvedElement) && resolvedElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
@@ -76,43 +112,56 @@ internal sealed class NodeLockData
|
||||
integrity = integrityElement.GetString();
|
||||
}
|
||||
|
||||
if (version is null && resolved is null && integrity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (version is null && resolved is null && integrity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var locatorValue = string.IsNullOrWhiteSpace(locator) ? null : locator;
|
||||
return new NodeLockEntry(source, locatorValue, name!, version, resolved, integrity);
|
||||
}
|
||||
|
||||
return new NodeLockEntry(version, resolved, integrity);
|
||||
}
|
||||
private static void TraverseLegacyDependencies(
|
||||
string currentPath,
|
||||
JsonElement dependenciesElement,
|
||||
IDictionary<string, NodeLockEntry> byPath,
|
||||
IDictionary<string, NodeLockEntry> byName,
|
||||
IDictionary<string, NodeLockEntry> declared)
|
||||
{
|
||||
foreach (var dependency in dependenciesElement.EnumerateObject())
|
||||
{
|
||||
var depValue = dependency.Value;
|
||||
var path = $"{currentPath}/{dependency.Name}";
|
||||
var normalizedPath = NormalizeLockPath(path);
|
||||
var entry = CreateEntry(PackageLockSource, normalizedPath, dependency.Name, depValue);
|
||||
if (entry is not null)
|
||||
{
|
||||
byPath[normalizedPath] = entry;
|
||||
byName[dependency.Name] = entry;
|
||||
AddDeclaration(declared, entry);
|
||||
}
|
||||
|
||||
if (depValue.TryGetProperty("dependencies", out var childDependencies) && childDependencies.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
TraverseLegacyDependencies(path + "/node_modules", childDependencies, byPath, byName, declared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraverseLegacyDependencies(
|
||||
string currentPath,
|
||||
JsonElement dependenciesElement,
|
||||
IDictionary<string, NodeLockEntry> byPath,
|
||||
IDictionary<string, NodeLockEntry> byName)
|
||||
{
|
||||
foreach (var dependency in dependenciesElement.EnumerateObject())
|
||||
{
|
||||
var depValue = dependency.Value;
|
||||
var path = $"{currentPath}/{dependency.Name}";
|
||||
var entry = CreateEntry(depValue);
|
||||
if (entry is not null)
|
||||
{
|
||||
var normalizedPath = NormalizeLockPath(path);
|
||||
byPath[normalizedPath] = entry;
|
||||
byName[dependency.Name] = entry;
|
||||
}
|
||||
|
||||
if (depValue.TryGetProperty("dependencies", out var childDependencies) && childDependencies.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
TraverseLegacyDependencies(path + "/node_modules", childDependencies, byPath, byName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadPackageLockJson(string rootPath, IDictionary<string, NodeLockEntry> byPath, IDictionary<string, NodeLockEntry> byName, CancellationToken cancellationToken)
|
||||
{
|
||||
var packageLockPath = Path.Combine(rootPath, "package-lock.json");
|
||||
if (!File.Exists(packageLockPath))
|
||||
private static void LoadPackageLockJson(
|
||||
string rootPath,
|
||||
IDictionary<string, NodeLockEntry> byPath,
|
||||
IDictionary<string, NodeLockEntry> byName,
|
||||
IDictionary<string, NodeLockEntry> declared,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packageLockPath = Path.Combine(rootPath, "package-lock.json");
|
||||
if (!File.Exists(packageLockPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -127,38 +176,32 @@ internal sealed class NodeLockData
|
||||
|
||||
if (root.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var packageProperty in packagesElement.EnumerateObject())
|
||||
{
|
||||
var entry = CreateEntry(packageProperty.Value);
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = NormalizeLockPath(packageProperty.Name);
|
||||
byPath[key] = entry;
|
||||
|
||||
var name = ExtractNameFromPath(key);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
byName[name] = entry;
|
||||
}
|
||||
|
||||
if (packageProperty.Value.TryGetProperty("name", out var explicitNameElement) && explicitNameElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var explicitName = explicitNameElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(explicitName))
|
||||
{
|
||||
byName[explicitName] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
TraverseLegacyDependencies("node_modules", dependenciesElement, byPath, byName);
|
||||
}
|
||||
}
|
||||
foreach (var packageProperty in packagesElement.EnumerateObject())
|
||||
{
|
||||
var key = NormalizeLockPath(packageProperty.Name);
|
||||
var inferredName = ExtractNameFromPath(key);
|
||||
|
||||
var entry = CreateEntry(PackageLockSource, key, inferredName, packageProperty.Value);
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
byPath[key] = entry;
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
byName[entry.Name] = entry;
|
||||
}
|
||||
|
||||
AddDeclaration(declared, entry);
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
TraverseLegacyDependencies("node_modules", dependenciesElement, byPath, byName, declared);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore unreadable package-lock.
|
||||
@@ -169,7 +212,10 @@ internal sealed class NodeLockData
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadYarnLock(string rootPath, IDictionary<string, NodeLockEntry> byName)
|
||||
private static void LoadYarnLock(
|
||||
string rootPath,
|
||||
IDictionary<string, NodeLockEntry> byName,
|
||||
IDictionary<string, NodeLockEntry> declared)
|
||||
{
|
||||
var yarnLockPath = Path.Combine(rootPath, "yarn.lock");
|
||||
if (!File.Exists(yarnLockPath))
|
||||
@@ -185,31 +231,32 @@ internal sealed class NodeLockData
|
||||
string? resolved = null;
|
||||
string? integrity = null;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentName))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var simpleName = ExtractPackageNameFromYarnKey(currentName!);
|
||||
if (string.IsNullOrEmpty(simpleName))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new NodeLockEntry(version, resolved, integrity);
|
||||
byName[simpleName] = entry;
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
}
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentName))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var simpleName = ExtractPackageNameFromYarnKey(currentName!);
|
||||
if (string.IsNullOrEmpty(simpleName))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new NodeLockEntry(YarnLockSource, currentName, simpleName, version, resolved, integrity);
|
||||
byName[simpleName] = entry;
|
||||
AddDeclaration(declared, entry);
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
@@ -250,7 +297,10 @@ internal sealed class NodeLockData
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadPnpmLock(string rootPath, IDictionary<string, NodeLockEntry> byName)
|
||||
private static void LoadPnpmLock(
|
||||
string rootPath,
|
||||
IDictionary<string, NodeLockEntry> byName,
|
||||
IDictionary<string, NodeLockEntry> declared)
|
||||
{
|
||||
var pnpmLockPath = Path.Combine(rootPath, "pnpm-lock.yaml");
|
||||
if (!File.Exists(pnpmLockPath))
|
||||
@@ -258,94 +308,107 @@ internal sealed class NodeLockData
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(pnpmLockPath);
|
||||
string? currentPackage = null;
|
||||
string? version = null;
|
||||
string? resolved = null;
|
||||
string? integrity = null;
|
||||
var inPackages = false;
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackages)
|
||||
{
|
||||
if (line.StartsWith("packages:", StringComparison.Ordinal))
|
||||
{
|
||||
inPackages = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" /", StringComparison.Ordinal))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(currentPackage) && !string.IsNullOrEmpty(integrity))
|
||||
{
|
||||
var name = ExtractNameFromPnpmKey(currentPackage);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
byName[name] = new NodeLockEntry(version, resolved, integrity);
|
||||
}
|
||||
}
|
||||
|
||||
currentPackage = line.Trim().TrimEnd(':').TrimStart('/');
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(currentPackage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("resolution:", StringComparison.Ordinal))
|
||||
{
|
||||
var integrityIndex = trimmed.IndexOf("integrity", StringComparison.OrdinalIgnoreCase);
|
||||
if (integrityIndex >= 0)
|
||||
{
|
||||
var integrityValue = trimmed[(integrityIndex + 9)..].Trim(' ', ':', '{', '}', '"');
|
||||
integrity = integrityValue;
|
||||
}
|
||||
|
||||
var tarballIndex = trimmed.IndexOf("tarball", StringComparison.OrdinalIgnoreCase);
|
||||
if (tarballIndex >= 0)
|
||||
{
|
||||
var tarballValue = trimmed[(tarballIndex + 7)..].Trim(' ', ':', '{', '}', '"');
|
||||
resolved = tarballValue;
|
||||
}
|
||||
}
|
||||
else if (trimmed.StartsWith("integrity:", StringComparison.Ordinal))
|
||||
{
|
||||
integrity = trimmed[("integrity:".Length)..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("tarball:", StringComparison.Ordinal))
|
||||
{
|
||||
resolved = trimmed[("tarball:".Length)..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("version:", StringComparison.Ordinal))
|
||||
{
|
||||
version = trimmed[("version:".Length)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentPackage) && !string.IsNullOrEmpty(integrity))
|
||||
{
|
||||
var name = ExtractNameFromPnpmKey(currentPackage);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
byName[name] = new NodeLockEntry(version, resolved, integrity);
|
||||
}
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(pnpmLockPath);
|
||||
string? currentPackage = null;
|
||||
string? version = null;
|
||||
string? resolved = null;
|
||||
string? integrity = null;
|
||||
var inPackages = false;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrEmpty(currentPackage) || string.IsNullOrEmpty(integrity))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var name = ExtractNameFromPnpmKey(currentPackage!);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new NodeLockEntry(PnpmLockSource, currentPackage, name, version, resolved, integrity);
|
||||
byName[name] = entry;
|
||||
AddDeclaration(declared, entry);
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
}
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackages)
|
||||
{
|
||||
if (line.StartsWith("packages:", StringComparison.Ordinal))
|
||||
{
|
||||
inPackages = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" /", StringComparison.Ordinal))
|
||||
{
|
||||
Flush();
|
||||
|
||||
currentPackage = line.Trim().TrimEnd(':').TrimStart('/');
|
||||
version = null;
|
||||
resolved = null;
|
||||
integrity = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(currentPackage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("resolution:", StringComparison.Ordinal))
|
||||
{
|
||||
var integrityIndex = trimmed.IndexOf("integrity", StringComparison.OrdinalIgnoreCase);
|
||||
if (integrityIndex >= 0)
|
||||
{
|
||||
var integrityValue = trimmed[(integrityIndex + 9)..].Trim(' ', ':', '{', '}', '"');
|
||||
integrity = integrityValue;
|
||||
}
|
||||
|
||||
var tarballIndex = trimmed.IndexOf("tarball", StringComparison.OrdinalIgnoreCase);
|
||||
if (tarballIndex >= 0)
|
||||
{
|
||||
var tarballValue = trimmed[(tarballIndex + 7)..].Trim(' ', ':', '{', '}', '"');
|
||||
resolved = tarballValue;
|
||||
}
|
||||
}
|
||||
else if (trimmed.StartsWith("integrity:", StringComparison.Ordinal))
|
||||
{
|
||||
integrity = trimmed[("integrity:".Length)..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("tarball:", StringComparison.Ordinal))
|
||||
{
|
||||
resolved = trimmed[("tarball:".Length)..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("version:", StringComparison.Ordinal))
|
||||
{
|
||||
version = trimmed[("version:".Length)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore unreadable pnpm lock file.
|
||||
@@ -384,9 +447,9 @@ internal sealed class NodeLockData
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string ExtractNameFromPnpmKey(string key)
|
||||
{
|
||||
var parts = key.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
private static string ExtractNameFromPnpmKey(string key)
|
||||
{
|
||||
var parts = key.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
@@ -397,8 +460,27 @@ internal sealed class NodeLockData
|
||||
return parts.Length >= 2 ? $"{parts[0]}/{parts[1]}" : parts[0];
|
||||
}
|
||||
|
||||
return parts[0];
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
private static void AddDeclaration(IDictionary<string, NodeLockEntry> declared, NodeLockEntry entry)
|
||||
{
|
||||
if (declared is null || entry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(entry.Version))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = entry.DeclarationKey();
|
||||
if (!declared.ContainsKey(key))
|
||||
{
|
||||
declared[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLockPath(string path)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||
|
||||
internal sealed record NodeLockEntry(string? Version, string? Resolved, string? Integrity);
|
||||
internal sealed record NodeLockEntry(
|
||||
string Source,
|
||||
string? Locator,
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Resolved,
|
||||
string? Integrity);
|
||||
|
||||
internal static class NodeLockEntryExtensions
|
||||
{
|
||||
public static string DeclarationKey(this NodeLockEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var version = entry.Version ?? string.Empty;
|
||||
return $"{entry.Name}@{version}".ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,23 +13,29 @@ internal sealed class NodePackage
|
||||
string? workspaceRoot,
|
||||
IReadOnlyList<string> workspaceTargets,
|
||||
string? workspaceLink,
|
||||
IReadOnlyList<NodeLifecycleScript> lifecycleScripts,
|
||||
bool usedByEntrypoint)
|
||||
{
|
||||
Name = name;
|
||||
Version = version;
|
||||
RelativePath = relativePath;
|
||||
PackageJsonLocator = packageJsonLocator;
|
||||
IReadOnlyList<NodeLifecycleScript> lifecycleScripts,
|
||||
bool usedByEntrypoint,
|
||||
bool declaredOnly = false,
|
||||
string? lockSource = null,
|
||||
string? lockLocator = null)
|
||||
{
|
||||
Name = name;
|
||||
Version = version;
|
||||
RelativePath = relativePath;
|
||||
PackageJsonLocator = packageJsonLocator;
|
||||
IsPrivate = isPrivate;
|
||||
LockEntry = lockEntry;
|
||||
IsWorkspaceMember = isWorkspaceMember;
|
||||
WorkspaceRoot = workspaceRoot;
|
||||
WorkspaceTargets = workspaceTargets;
|
||||
WorkspaceLink = workspaceLink;
|
||||
LifecycleScripts = lifecycleScripts ?? Array.Empty<NodeLifecycleScript>();
|
||||
IsUsedByEntrypoint = usedByEntrypoint;
|
||||
}
|
||||
|
||||
WorkspaceLink = workspaceLink;
|
||||
LifecycleScripts = lifecycleScripts ?? Array.Empty<NodeLifecycleScript>();
|
||||
IsUsedByEntrypoint = usedByEntrypoint;
|
||||
DeclaredOnly = declaredOnly;
|
||||
LockSource = lockSource;
|
||||
LockLocator = lockLocator;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Version { get; }
|
||||
@@ -50,11 +56,17 @@ internal sealed class NodePackage
|
||||
|
||||
public string? WorkspaceLink { get; }
|
||||
|
||||
public IReadOnlyList<NodeLifecycleScript> LifecycleScripts { get; }
|
||||
|
||||
public bool HasInstallScripts => LifecycleScripts.Count > 0;
|
||||
|
||||
public bool IsUsedByEntrypoint { get; }
|
||||
public IReadOnlyList<NodeLifecycleScript> LifecycleScripts { get; }
|
||||
|
||||
public bool HasInstallScripts => LifecycleScripts.Count > 0;
|
||||
|
||||
public bool IsUsedByEntrypoint { get; }
|
||||
|
||||
public bool DeclaredOnly { get; }
|
||||
|
||||
public string? LockSource { get; }
|
||||
|
||||
public string? LockLocator { get; }
|
||||
|
||||
public string RelativePathNormalized => string.IsNullOrEmpty(RelativePath) ? string.Empty : RelativePath.Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
@@ -64,10 +76,10 @@ internal sealed class NodePackage
|
||||
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
|
||||
{
|
||||
var evidence = new List<LanguageComponentEvidence>
|
||||
{
|
||||
new LanguageComponentEvidence(LanguageEvidenceKind.File, "package.json", PackageJsonLocator, Value: null, Sha256: null)
|
||||
};
|
||||
var evidence = new List<LanguageComponentEvidence>
|
||||
{
|
||||
CreateRootEvidence()
|
||||
};
|
||||
|
||||
foreach (var script in LifecycleScripts)
|
||||
{
|
||||
@@ -83,8 +95,8 @@ internal sealed class NodePackage
|
||||
script.Sha256));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
@@ -111,11 +123,11 @@ internal sealed class NodePackage
|
||||
}
|
||||
}
|
||||
|
||||
if (IsWorkspaceMember)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("workspaceMember", "true"));
|
||||
if (!string.IsNullOrWhiteSpace(WorkspaceRoot))
|
||||
{
|
||||
if (IsWorkspaceMember)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("workspaceMember", "true"));
|
||||
if (!string.IsNullOrWhiteSpace(WorkspaceRoot))
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("workspaceRoot", WorkspaceRoot));
|
||||
}
|
||||
}
|
||||
@@ -148,12 +160,27 @@ internal sealed class NodePackage
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>($"script.{script.Name}", script.Command));
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (DeclaredOnly)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("declaredOnly", "true"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LockSource))
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("lockSource", LockSource));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LockLocator))
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("lockLocator", LockLocator));
|
||||
}
|
||||
|
||||
return entries
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildPurl(string name, string version)
|
||||
{
|
||||
@@ -174,6 +201,21 @@ internal sealed class NodePackage
|
||||
return $"%40{scopeAndName}";
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private LanguageComponentEvidence CreateRootEvidence()
|
||||
{
|
||||
var evidenceSource = DeclaredOnly
|
||||
? string.IsNullOrWhiteSpace(LockSource) ? "lockfile" : LockSource!
|
||||
: "package.json";
|
||||
|
||||
var locator = DeclaredOnly
|
||||
? (string.IsNullOrWhiteSpace(LockLocator) ? evidenceSource : LockLocator!)
|
||||
: (string.IsNullOrWhiteSpace(PackageJsonLocator) ? "package.json" : PackageJsonLocator);
|
||||
|
||||
var kind = DeclaredOnly ? LanguageEvidenceKind.Metadata : LanguageEvidenceKind.File;
|
||||
|
||||
return new LanguageComponentEvidence(kind, evidenceSource, locator, Value: null, Sha256: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ internal static class NodePackageCollector
|
||||
TraverseDirectory(context, pendingRoot, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
AppendDeclaredPackages(packages, lockData);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static void TraverseDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
@@ -166,18 +168,94 @@ internal static class NodePackageCollector
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraverseNestedNodeModules(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nestedNodeModules = Path.Combine(directory, "node_modules");
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
private static void TraverseNestedNodeModules(
|
||||
LanguageAnalyzerContext context,
|
||||
string directory,
|
||||
NodeLockData lockData,
|
||||
NodeWorkspaceIndex workspaceIndex,
|
||||
List<NodePackage> packages,
|
||||
HashSet<string> visited,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nestedNodeModules = Path.Combine(directory, "node_modules");
|
||||
TraverseDirectory(context, nestedNodeModules, lockData, workspaceIndex, packages, visited, cancellationToken);
|
||||
}
|
||||
|
||||
private static void AppendDeclaredPackages(List<NodePackage> packages, NodeLockData lockData)
|
||||
{
|
||||
if (lockData.DeclaredPackages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var observed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var package in packages)
|
||||
{
|
||||
var key = BuildDeclarationKey(package.Name, package.Version);
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
observed.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in lockData.DeclaredPackages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(entry.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = BuildDeclarationKey(entry.Name, entry.Version);
|
||||
if (string.IsNullOrEmpty(key) || !observed.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var declaredPackage = new NodePackage(
|
||||
entry.Name,
|
||||
entry.Version,
|
||||
relativePath: string.Empty,
|
||||
packageJsonLocator: string.Empty,
|
||||
isPrivate: null,
|
||||
lockEntry: entry,
|
||||
isWorkspaceMember: false,
|
||||
workspaceRoot: null,
|
||||
workspaceTargets: Array.Empty<string>(),
|
||||
workspaceLink: null,
|
||||
lifecycleScripts: Array.Empty<NodeLifecycleScript>(),
|
||||
usedByEntrypoint: false,
|
||||
declaredOnly: true,
|
||||
lockSource: entry.Source,
|
||||
lockLocator: BuildLockLocator(entry));
|
||||
|
||||
packages.Add(declaredPackage);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildDeclarationKey(string name, string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{name}@{version}".ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? BuildLockLocator(NodeLockEntry? entry)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Locator))
|
||||
{
|
||||
return entry.Source;
|
||||
}
|
||||
|
||||
return $"{entry.Source}:{entry.Locator}";
|
||||
}
|
||||
|
||||
private static NodePackage? TryCreatePackage(
|
||||
LanguageAnalyzerContext context,
|
||||
@@ -221,9 +299,11 @@ internal static class NodePackageCollector
|
||||
isPrivate = privateElement.GetBoolean();
|
||||
}
|
||||
|
||||
var lockEntry = lockData.TryGet(relativeDirectory, name, out var entry) ? entry : null;
|
||||
var locator = BuildLocator(relativeDirectory);
|
||||
var usedByEntrypoint = context.UsageHints.IsPathUsed(packageJsonPath);
|
||||
var lockEntry = lockData.TryGet(relativeDirectory, name, out var entry) ? entry : null;
|
||||
var locator = BuildLocator(relativeDirectory);
|
||||
var usedByEntrypoint = context.UsageHints.IsPathUsed(packageJsonPath);
|
||||
var lockLocator = BuildLockLocator(lockEntry);
|
||||
var lockSource = lockEntry?.Source;
|
||||
|
||||
var isWorkspaceMember = workspaceIndex.TryGetMember(relativeDirectory, out var workspaceRoot);
|
||||
var workspaceTargets = ExtractWorkspaceTargets(relativeDirectory, root, workspaceIndex);
|
||||
@@ -232,19 +312,22 @@ internal static class NodePackageCollector
|
||||
: null;
|
||||
var lifecycleScripts = ExtractLifecycleScripts(root);
|
||||
|
||||
return new NodePackage(
|
||||
name: name.Trim(),
|
||||
version: version.Trim(),
|
||||
relativePath: relativeDirectory,
|
||||
packageJsonLocator: locator,
|
||||
isPrivate: isPrivate,
|
||||
lockEntry: lockEntry,
|
||||
isWorkspaceMember: isWorkspaceMember,
|
||||
workspaceRoot: workspaceRoot,
|
||||
workspaceTargets: workspaceTargets,
|
||||
workspaceLink: workspaceLink,
|
||||
lifecycleScripts: lifecycleScripts,
|
||||
usedByEntrypoint: usedByEntrypoint);
|
||||
return new NodePackage(
|
||||
name: name.Trim(),
|
||||
version: version.Trim(),
|
||||
relativePath: relativeDirectory,
|
||||
packageJsonLocator: locator,
|
||||
isPrivate: isPrivate,
|
||||
lockEntry: lockEntry,
|
||||
isWorkspaceMember: isWorkspaceMember,
|
||||
workspaceRoot: workspaceRoot,
|
||||
workspaceTargets: workspaceTargets,
|
||||
workspaceLink: workspaceLink,
|
||||
lifecycleScripts: lifecycleScripts,
|
||||
usedByEntrypoint: usedByEntrypoint,
|
||||
declaredOnly: false,
|
||||
lockSource: lockSource,
|
||||
lockLocator: lockLocator);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user