feat(scanner): Implement Deno analyzer and associated tests
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 Deno analyzer with comprehensive metadata and evidence structure. - Created a detailed implementation plan for Sprint 130 focusing on Deno analyzer. - Introduced AdvisoryAiGuardrailOptions for managing guardrail configurations. - Developed GuardrailPhraseLoader for loading blocked phrases from JSON files. - Implemented tests for AdvisoryGuardrailOptions binding and phrase loading. - Enhanced telemetry for Advisory AI with metrics tracking. - Added VexObservationProjectionService for querying VEX observations. - Created extensive tests for VexObservationProjectionService functionality. - Introduced Ruby language analyzer with tests for simple and complex workspaces. - Added Ruby application fixtures for testing purposes.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Internal;
|
||||
|
||||
internal sealed class DenoConfigDocument
|
||||
@@ -12,7 +15,8 @@ internal sealed class DenoConfigDocument
|
||||
bool vendorEnabled,
|
||||
string? vendorDirectoryPath,
|
||||
bool nodeModulesDirEnabled,
|
||||
string? nodeModulesDir)
|
||||
string? nodeModulesDir,
|
||||
ImmutableArray<string> entrypoints)
|
||||
{
|
||||
AbsolutePath = Path.GetFullPath(absolutePath);
|
||||
RelativePath = DenoPathUtilities.NormalizeRelativePath(relativePath);
|
||||
@@ -25,6 +29,7 @@ internal sealed class DenoConfigDocument
|
||||
VendorDirectoryPath = vendorDirectoryPath;
|
||||
NodeModulesDirEnabled = nodeModulesDirEnabled;
|
||||
NodeModulesDirectory = nodeModulesDir;
|
||||
Entrypoints = entrypoints;
|
||||
}
|
||||
|
||||
public string AbsolutePath { get; }
|
||||
@@ -49,6 +54,8 @@ internal sealed class DenoConfigDocument
|
||||
|
||||
public string? NodeModulesDirectory { get; }
|
||||
|
||||
public ImmutableArray<string> Entrypoints { get; }
|
||||
|
||||
public static bool TryLoad(
|
||||
string absolutePath,
|
||||
string relativePath,
|
||||
@@ -80,6 +87,7 @@ internal sealed class DenoConfigDocument
|
||||
var (lockEnabled, lockFilePath) = ResolveLockPath(root, directory);
|
||||
var (vendorEnabled, vendorDirectory) = ResolveVendorDirectory(root, directory);
|
||||
var (nodeModulesDirEnabled, nodeModulesDir) = ResolveNodeModulesDirectory(root, directory);
|
||||
var entrypoints = ResolveEntrypoints(root, directory);
|
||||
|
||||
document = new DenoConfigDocument(
|
||||
absolutePath,
|
||||
@@ -91,7 +99,8 @@ internal sealed class DenoConfigDocument
|
||||
vendorEnabled,
|
||||
vendorDirectory,
|
||||
nodeModulesDirEnabled,
|
||||
nodeModulesDir);
|
||||
nodeModulesDir,
|
||||
entrypoints);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -192,6 +201,79 @@ internal sealed class DenoConfigDocument
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ResolveEntrypoints(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("tasks", out var tasksElement) || tasksElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
foreach (var task in tasksElement.EnumerateObject())
|
||||
{
|
||||
if (task.Value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var command = task.Value.GetString();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in ExtractEntrypointCandidates(command))
|
||||
{
|
||||
var normalized = NormalizeEntrypoint(directory, candidate);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
builder.Add(normalized!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
|
||||
.Select(static entry => entry!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ExtractEntrypointCandidates(string command)
|
||||
{
|
||||
foreach (Match match in EntrypointRegex.Matches(command ?? string.Empty))
|
||||
{
|
||||
var path = match.Groups["path"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
yield return path.Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeEntrypoint(string directory, string candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string fullPath = Path.IsPathFullyQualified(candidate)
|
||||
? candidate
|
||||
: Path.Combine(directory, candidate);
|
||||
|
||||
fullPath = Path.GetFullPath(fullPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(directory, fullPath);
|
||||
return DenoPathUtilities.NormalizeRelativePath(relative);
|
||||
}
|
||||
|
||||
private static (bool Enabled, string? Path) ResolveLockPath(JsonElement root, string directory)
|
||||
{
|
||||
if (!root.TryGetProperty("lock", out var lockElement))
|
||||
@@ -327,4 +409,8 @@ internal sealed class DenoConfigDocument
|
||||
_ => (false, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly Regex EntrypointRegex = new(
|
||||
@"(?<path>(?:\.\.?/|/)[^""'\s]+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ internal static class DenoModuleGraphResolver
|
||||
foreach (var config in workspace.Configurations)
|
||||
{
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["vendor.enabled"] = config.VendorEnabled.ToString(CultureInfo.InvariantCulture),
|
||||
["lock.enabled"] = config.LockEnabled.ToString(CultureInfo.InvariantCulture),
|
||||
["nodeModules.enabled"] = config.NodeModulesDirEnabled.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
if (config.Entrypoints.Length > 0)
|
||||
{
|
||||
metadata["entrypoints"] = string.Join(";", config.Entrypoints);
|
||||
}
|
||||
|
||||
var configNodeId = GetOrAddNode(
|
||||
$"config::{config.RelativePath}",
|
||||
config.RelativePath,
|
||||
@@ -45,12 +57,7 @@ internal static class DenoModuleGraphResolver
|
||||
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),
|
||||
});
|
||||
metadata: metadata);
|
||||
|
||||
if (config.ImportMapPath is not null)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
|
||||
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 Regex DynamicImportIdentifierRegex = new(@"import\s*\(\s*(?<identifier>[A-Za-z_][A-Za-z0-9_]*)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex LiteralFetchIdentifierRegex = new(@"fetch\s*\(\s*(?<identifier>[A-Za-z_][A-Za-z0-9_]*)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex LiteralAssignmentRegex = new(@"(?:(?:const|let|var)\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*['""](?<url>https?://[^'""]+)['""]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> SourceFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -67,19 +70,41 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
private static ImmutableArray<DenoBuiltinUsage> CollectBuiltins(DenoModuleGraph graph)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DenoBuiltinUsage>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (edge.Specifier.StartsWith("node:", StringComparison.OrdinalIgnoreCase) ||
|
||||
edge.Specifier.StartsWith("deno:", StringComparison.OrdinalIgnoreCase))
|
||||
foreach (var candidate in EnumerateBuiltinCandidates(edge))
|
||||
{
|
||||
builder.Add(new DenoBuiltinUsage(edge.Specifier, edge.FromId, edge.Provenance));
|
||||
var key = $"{candidate}::{edge.FromId}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
builder.Add(new DenoBuiltinUsage(candidate, edge.FromId, edge.Provenance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateBuiltinCandidates(DenoModuleEdge edge)
|
||||
{
|
||||
if (IsBuiltin(edge.Specifier))
|
||||
{
|
||||
yield return edge.Specifier;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Resolution) && IsBuiltin(edge.Resolution))
|
||||
{
|
||||
yield return edge.Resolution!;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBuiltin(string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value) &&
|
||||
(value.StartsWith("node:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("deno:", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static ImmutableArray<DenoNpmResolution> ResolveNpmPackages(
|
||||
DenoWorkspace workspace,
|
||||
DenoModuleGraph graph,
|
||||
@@ -326,6 +351,7 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
}
|
||||
|
||||
var lineNumber = 0;
|
||||
var literalAssignments = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
using var stream = new StreamReader(file.AbsolutePath);
|
||||
string? line;
|
||||
while ((line = stream.ReadLine()) is not null)
|
||||
@@ -333,6 +359,18 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
lineNumber++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (Match assignment in LiteralAssignmentRegex.Matches(line))
|
||||
{
|
||||
var name = assignment.Groups["name"].Value;
|
||||
var url = assignment.Groups["url"].Value;
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
literalAssignments[name] = url;
|
||||
}
|
||||
|
||||
foreach (Match match in DynamicImportRegex.Matches(line))
|
||||
{
|
||||
var specifier = match.Groups["url"].Value;
|
||||
@@ -362,6 +400,36 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
url,
|
||||
"network.fetch.literal"));
|
||||
}
|
||||
|
||||
foreach (Match match in DynamicImportIdentifierRegex.Matches(line))
|
||||
{
|
||||
var identifier = match.Groups["identifier"].Value;
|
||||
if (!literalAssignments.TryGetValue(identifier, out var url) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamicBuilder.Add(new DenoDynamicImportObservation(
|
||||
file.AbsolutePath,
|
||||
lineNumber,
|
||||
url,
|
||||
"network.dynamic_import.identifier"));
|
||||
}
|
||||
|
||||
foreach (Match match in LiteralFetchIdentifierRegex.Matches(line))
|
||||
{
|
||||
var identifier = match.Groups["identifier"].Value;
|
||||
if (!literalAssignments.TryGetValue(identifier, out var url) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchBuilder.Add(new DenoLiteralFetchObservation(
|
||||
file.AbsolutePath,
|
||||
lineNumber,
|
||||
url,
|
||||
"network.fetch.identifier"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,11 +68,14 @@ internal sealed class DenoVirtualFileSystem
|
||||
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);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddConfigFiles(context, configs, files, seen, cancellationToken);
|
||||
AddImportMaps(context, importMaps, files, seen, cancellationToken);
|
||||
AddLockFiles(context, lockFiles, files, seen, cancellationToken);
|
||||
AddVendorFiles(vendors, files, seen, cancellationToken);
|
||||
AddCacheFiles(cacheLocations, files, seen, cancellationToken);
|
||||
AddWorkspaceFiles(context, vendors, cacheLocations, files, seen, cancellationToken);
|
||||
|
||||
return new DenoVirtualFileSystem(files);
|
||||
}
|
||||
@@ -81,6 +84,7 @@ internal sealed class DenoVirtualFileSystem
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoConfigDocument> configs,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var config in configs ?? Array.Empty<DenoConfigDocument>())
|
||||
@@ -89,34 +93,45 @@ internal sealed class DenoVirtualFileSystem
|
||||
|
||||
if (File.Exists(config.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
config.AbsolutePath,
|
||||
context.GetRelativePath(config.AbsolutePath),
|
||||
DenoVirtualFileSource.Workspace,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.AbsolutePath)));
|
||||
DenoLayerMetadata.TryExtractDigest(config.AbsolutePath));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ImportMapPath) && File.Exists(config.ImportMapPath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
config.ImportMapPath!,
|
||||
context.GetRelativePath(config.ImportMapPath!),
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.ImportMapPath!)));
|
||||
DenoLayerMetadata.TryExtractDigest(config.ImportMapPath!));
|
||||
}
|
||||
|
||||
if (config.LockEnabled && !string.IsNullOrWhiteSpace(config.LockFilePath) && File.Exists(config.LockFilePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
config.LockFilePath!,
|
||||
context.GetRelativePath(config.LockFilePath!),
|
||||
DenoVirtualFileSource.LockFile,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(config.LockFilePath!)));
|
||||
DenoLayerMetadata.TryExtractDigest(config.LockFilePath!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddImportMaps(IEnumerable<DenoImportMapDocument> maps, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
private static void AddImportMaps(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoImportMapDocument> maps,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var map in maps ?? Array.Empty<DenoImportMapDocument>())
|
||||
{
|
||||
@@ -127,15 +142,26 @@ internal sealed class DenoVirtualFileSystem
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(CreateVirtualFile(
|
||||
var virtualPath = string.IsNullOrWhiteSpace(map.Origin)
|
||||
? context.GetRelativePath(map.AbsolutePath)
|
||||
: map.Origin;
|
||||
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
map.AbsolutePath,
|
||||
map.Origin,
|
||||
virtualPath,
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(map.AbsolutePath)));
|
||||
DenoLayerMetadata.TryExtractDigest(map.AbsolutePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddLockFiles(IEnumerable<DenoLockFile> lockFiles, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
private static void AddLockFiles(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoLockFile> lockFiles,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var lockFile in lockFiles ?? Array.Empty<DenoLockFile>())
|
||||
{
|
||||
@@ -146,15 +172,25 @@ internal sealed class DenoVirtualFileSystem
|
||||
continue;
|
||||
}
|
||||
|
||||
files.Add(CreateVirtualFile(
|
||||
var virtualPath = string.IsNullOrWhiteSpace(lockFile.RelativePath)
|
||||
? context.GetRelativePath(lockFile.AbsolutePath)
|
||||
: lockFile.RelativePath;
|
||||
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
lockFile.AbsolutePath,
|
||||
lockFile.RelativePath,
|
||||
virtualPath,
|
||||
DenoVirtualFileSource.LockFile,
|
||||
layerDigest: DenoLayerMetadata.TryExtractDigest(lockFile.AbsolutePath)));
|
||||
DenoLayerMetadata.TryExtractDigest(lockFile.AbsolutePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddVendorFiles(IEnumerable<DenoVendorDirectory> vendors, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
private static void AddVendorFiles(
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var vendor in vendors ?? Array.Empty<DenoVendorDirectory>())
|
||||
{
|
||||
@@ -168,34 +204,45 @@ internal sealed class DenoVirtualFileSystem
|
||||
foreach (var file in SafeEnumerateFiles(vendor.AbsolutePath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
files.Add(CreateVirtualFile(
|
||||
var virtualPath = $"vendor://{vendor.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(vendor.AbsolutePath, file))}";
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
file,
|
||||
$"vendor://{vendor.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(vendor.AbsolutePath, file))}",
|
||||
virtualPath,
|
||||
DenoVirtualFileSource.Vendor,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file)));
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file));
|
||||
}
|
||||
|
||||
if (vendor.ImportMap is { AbsolutePath: not null } importMapFile && File.Exists(importMapFile.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
importMapFile.AbsolutePath,
|
||||
$"vendor://{vendor.Alias}/import_map.json",
|
||||
DenoVirtualFileSource.ImportMap,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(importMapFile.AbsolutePath)));
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(importMapFile.AbsolutePath));
|
||||
}
|
||||
|
||||
if (vendor.LockFile is { AbsolutePath: not null } vendorLock && File.Exists(vendorLock.AbsolutePath))
|
||||
{
|
||||
files.Add(CreateVirtualFile(
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
vendorLock.AbsolutePath,
|
||||
$"vendor://{vendor.Alias}/deno.lock",
|
||||
DenoVirtualFileSource.LockFile,
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(vendorLock.AbsolutePath)));
|
||||
vendor.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(vendorLock.AbsolutePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCacheFiles(IEnumerable<DenoCacheLocation> cacheLocations, ICollection<DenoVirtualFile> files, CancellationToken cancellationToken)
|
||||
private static void AddCacheFiles(
|
||||
IEnumerable<DenoCacheLocation> cacheLocations,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var cache in cacheLocations ?? Array.Empty<DenoCacheLocation>())
|
||||
{
|
||||
@@ -208,15 +255,105 @@ internal sealed class DenoVirtualFileSystem
|
||||
foreach (var file in SafeEnumerateFiles(cache.AbsolutePath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
files.Add(CreateVirtualFile(
|
||||
var virtualPath = $"deno-dir://{cache.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(cache.AbsolutePath, file))}";
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
file,
|
||||
$"deno-dir://{cache.Alias}/{DenoPathUtilities.NormalizeRelativePath(Path.GetRelativePath(cache.AbsolutePath, file))}",
|
||||
virtualPath,
|
||||
cache.Kind == DenoCacheLocationKind.Layer ? DenoVirtualFileSource.Layer : DenoVirtualFileSource.DenoDir,
|
||||
cache.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file)));
|
||||
cache.LayerDigest ?? DenoLayerMetadata.TryExtractDigest(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddWorkspaceFiles(
|
||||
LanguageAnalyzerContext context,
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
IEnumerable<DenoCacheLocation> cacheLocations,
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var skipRoots = BuildSkipRoots(vendors, cacheLocations);
|
||||
foreach (var file in SafeEnumerateFiles(context.RootPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (ShouldSkip(file, skipRoots))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryAddFile(
|
||||
files,
|
||||
seen,
|
||||
file,
|
||||
context.GetRelativePath(file),
|
||||
DenoVirtualFileSource.Workspace,
|
||||
DenoLayerMetadata.TryExtractDigest(file));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSkipRoots(
|
||||
IEnumerable<DenoVendorDirectory> vendors,
|
||||
IEnumerable<DenoCacheLocation> cacheLocations)
|
||||
{
|
||||
var list = new List<string>();
|
||||
|
||||
foreach (var vendor in vendors ?? Array.Empty<DenoVendorDirectory>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(vendor.AbsolutePath))
|
||||
{
|
||||
list.Add(Path.GetFullPath(vendor.AbsolutePath));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var cache in cacheLocations ?? Array.Empty<DenoCacheLocation>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cache.AbsolutePath))
|
||||
{
|
||||
list.Add(Path.GetFullPath(cache.AbsolutePath));
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(string path, IReadOnlyList<string> skipRoots)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
foreach (var root in skipRoots)
|
||||
{
|
||||
if (fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TryAddFile(
|
||||
ICollection<DenoVirtualFile> files,
|
||||
HashSet<string> seen,
|
||||
string absolutePath,
|
||||
string virtualPath,
|
||||
DenoVirtualFileSource source,
|
||||
string? layerDigest)
|
||||
{
|
||||
var normalized = Path.GetFullPath(absolutePath);
|
||||
if (!seen.Add($"{source}:{normalized}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
files.Add(CreateVirtualFile(
|
||||
normalized,
|
||||
virtualPath,
|
||||
source,
|
||||
layerDigest));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeEnumerateFiles(string root)
|
||||
{
|
||||
IEnumerable<string> iterator;
|
||||
|
||||
@@ -39,9 +39,24 @@ internal static class DenoObservationBuilder
|
||||
|
||||
foreach (var node in moduleGraph.Nodes)
|
||||
{
|
||||
if (node.Kind == DenoModuleKind.WorkspaceConfig &&
|
||||
node.Metadata.TryGetValue("entry", out var entry) &&
|
||||
!string.IsNullOrWhiteSpace(entry))
|
||||
if (node.Kind != DenoModuleKind.WorkspaceConfig)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.Metadata.TryGetValue("entrypoints", out var entries) &&
|
||||
!string.IsNullOrWhiteSpace(entries))
|
||||
{
|
||||
foreach (var entry in entries.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
entrypoints.Add(entry!);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (node.Metadata.TryGetValue("entry", out var entry) &&
|
||||
!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
entrypoints.Add(entry!);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ internal static class RubyObservationBuilder
|
||||
public static RubyObservationDocument Build(
|
||||
IReadOnlyList<RubyPackage> packages,
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities)
|
||||
RubyCapabilities capabilities,
|
||||
string? bundledWith)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
ArgumentNullException.ThrowIfNull(runtimeGraph);
|
||||
@@ -34,7 +35,11 @@ internal static class RubyObservationBuilder
|
||||
.OrderBy(static scheduler => scheduler, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray());
|
||||
|
||||
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary);
|
||||
var normalizedBundler = string.IsNullOrWhiteSpace(bundledWith)
|
||||
? null
|
||||
: bundledWith.Trim();
|
||||
|
||||
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary, normalizedBundler);
|
||||
}
|
||||
|
||||
private static RubyObservationPackage CreatePackage(RubyPackage package)
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
internal sealed record RubyObservationDocument(
|
||||
ImmutableArray<RubyObservationPackage> Packages,
|
||||
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
|
||||
RubyObservationCapabilitySummary Capabilities);
|
||||
RubyObservationCapabilitySummary Capabilities,
|
||||
string? BundledWith);
|
||||
|
||||
internal sealed record RubyObservationPackage(
|
||||
string Name,
|
||||
|
||||
@@ -20,6 +20,7 @@ internal static class RubyObservationSerializer
|
||||
WritePackages(writer, document.Packages);
|
||||
WriteRuntimeEdges(writer, document.RuntimeEdges);
|
||||
WriteCapabilities(writer, document.Capabilities);
|
||||
WriteBundledWith(writer, document.BundledWith);
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
@@ -100,6 +101,16 @@ internal static class RubyObservationSerializer
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteBundledWith(Utf8JsonWriter writer, string? bundledWith)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundledWith))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WriteString("bundledWith", bundledWith);
|
||||
}
|
||||
|
||||
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
|
||||
{
|
||||
writer.WritePropertyName(propertyName);
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
if (packages.Count > 0)
|
||||
{
|
||||
EmitObservation(context, writer, packages, runtimeGraph, capabilities);
|
||||
EmitObservation(context, writer, packages, runtimeGraph, capabilities, lockData.BundledWith);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<RubyPackage> packages,
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities)
|
||||
RubyCapabilities capabilities,
|
||||
string? bundledWith)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
@@ -95,7 +96,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(runtimeGraph);
|
||||
ArgumentNullException.ThrowIfNull(capabilities);
|
||||
|
||||
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities);
|
||||
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities, bundledWith);
|
||||
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
|
||||
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
|
||||
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
|
||||
@@ -103,7 +104,8 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
var observationMetadata = BuildObservationMetadata(
|
||||
packages.Count,
|
||||
observationDocument.RuntimeEdges.Length,
|
||||
observationDocument.Capabilities);
|
||||
observationDocument.Capabilities,
|
||||
observationDocument.BundledWith);
|
||||
|
||||
TryPersistObservation(Id, context, observationBytes, observationMetadata);
|
||||
|
||||
@@ -131,7 +133,8 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
private static IEnumerable<KeyValuePair<string, string?>> BuildObservationMetadata(
|
||||
int packageCount,
|
||||
int runtimeEdgeCount,
|
||||
RubyObservationCapabilitySummary capabilities)
|
||||
RubyObservationCapabilitySummary capabilities,
|
||||
string? bundledWith)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture));
|
||||
@@ -139,6 +142,17 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.serialization", capabilities.UsesSerialization ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.schedulers", capabilities.JobSchedulers.Length.ToString(CultureInfo.InvariantCulture));
|
||||
if (capabilities.JobSchedulers.Length > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"ruby.observation.capability.scheduler_list",
|
||||
string.Join(';', capabilities.JobSchedulers));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bundledWith))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.bundler_version", bundledWith);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryPersistObservation(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SCANNER-ENG-0009` | DOING (2025-11-12) | Added bundler-version metadata + observation summaries, richer CLI output, and the `complex-app` fixture to drive parity validation. |
|
||||
| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. |
|
||||
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
|
||||
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
|
||||
|
||||
Reference in New Issue
Block a user