feat(scanner): Implement Deno analyzer and associated tests
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:
master
2025-11-12 10:01:54 +02:00
parent 0e8655cbb1
commit babb81af52
75 changed files with 3346 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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