feat(ruby): Implement RubyManifestParser for parsing gem groups and dependencies
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

feat(ruby): Add RubyVendorArtifactCollector to collect vendor artifacts

test(deno): Add golden tests for Deno analyzer with various fixtures

test(deno): Create Deno module and package files for testing

test(deno): Implement Deno lock and import map for dependency management

test(deno): Add FFI and worker scripts for Deno testing

feat(ruby): Set up Ruby workspace with Gemfile and dependencies

feat(ruby): Add expected output for Ruby workspace tests

feat(signals): Introduce CallgraphManifest model for signal processing
This commit is contained in:
master
2025-11-10 09:27:03 +02:00
parent 69c59defdc
commit 56c687253f
87 changed files with 2462 additions and 542 deletions

View File

@@ -68,7 +68,7 @@ internal static class DenoContainerAdapter
builder.Add(new DenoContainerInput(
DenoContainerSourceKind.Bundle,
bundle.SourcePath,
layerDigest: null,
LayerDigest: null,
metadata,
bundle));
}

View File

@@ -463,9 +463,17 @@ internal static class DenoNpmCompatibilityAdapter
}
var trimmed = value.Replace('\\', '/');
return trimmed.StartsWith("./", StringComparison.Ordinal) || trimmed.StartsWith("/", StringComparison.Ordinal)
? trimmed.TrimStart('.')
: $"./{trimmed}";
if (trimmed.StartsWith("./", StringComparison.Ordinal))
{
trimmed = trimmed[2..];
}
else if (trimmed.StartsWith("/", StringComparison.Ordinal))
{
trimmed = trimmed.TrimStart('/');
}
return trimmed;
}
private readonly record struct NpmPackageKey(string Name, string Version);

View File

@@ -12,7 +12,7 @@ internal static class DenoObservationSerializer
{
ArgumentNullException.ThrowIfNull(document);
using var buffer = new ArrayBufferWriter<byte>();
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
{
writer.WriteStartObject();

View File

@@ -0,0 +1,105 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyBundlerConfig
{
private RubyBundlerConfig(IReadOnlyList<string> gemfiles, IReadOnlyList<string> bundlePaths)
{
Gemfiles = gemfiles;
BundlePaths = bundlePaths;
}
public IReadOnlyList<string> Gemfiles { get; }
public IReadOnlyList<string> BundlePaths { get; }
public static RubyBundlerConfig Empty { get; } = new(Array.Empty<string>(), Array.Empty<string>());
public static RubyBundlerConfig Load(string rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return Empty;
}
var configPath = Path.Combine(rootPath, \".bundle\", \"config\");
if (!File.Exists(configPath))
{
return Empty;
}
var gemfiles = new List<string>();
var bundlePaths = new List<string>();
try
{
foreach (var rawLine in File.ReadAllLines(configPath))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith(\"#\", StringComparison.Ordinal) || line.StartsWith(\"---\", StringComparison.Ordinal))
{
continue;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (value.Length == 0)
{
continue;
}
value = value.Trim('\"', '\'');
if (key.Equals(\"BUNDLE_GEMFILE\", StringComparison.OrdinalIgnoreCase))
{
AddPath(gemfiles, rootPath, value);
}
else if (key.Equals(\"BUNDLE_PATH\", StringComparison.OrdinalIgnoreCase))
{
AddPath(bundlePaths, rootPath, value);
}
}
}
catch (IOException)
{
return Empty;
}
catch (UnauthorizedAccessException)
{
return Empty;
}
return new RubyBundlerConfig(
DistinctNormalized(gemfiles),
DistinctNormalized(bundlePaths));
}
private static void AddPath(List<string> target, string rootPath, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var path = Path.IsPathRooted(value)
? value
: Path.Combine(rootPath, value);
target.Add(Path.GetFullPath(path));
}
private static IReadOnlyList<string> DistinctNormalized(IEnumerable<string> values)
{
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => Path.GetFullPath(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}

View File

@@ -0,0 +1,297 @@
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyLockCollector
{
private static readonly string[] LockFileNames = { "Gemfile.lock", "gems.locked" };
private static readonly string[] ManifestFileNames = { "Gemfile", "gems.rb" };
private static readonly string[] IgnoredDirectories =
{
".git",
".hg",
".svn",
".bundle",
"node_modules",
"vendor/bundle",
"vendor/cache",
"tmp",
"log",
"coverage"
};
private const int MaxDiscoveryDepth = 3;
private static readonly IReadOnlyCollection<string> DefaultGroups = new[] { "default" };
public static async ValueTask<RubyLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
var rootPath = context.RootPath;
var bundlerConfig = RubyBundlerConfig.Load(rootPath);
var lockFiles = DiscoverLockFiles(rootPath, bundlerConfig);
if (lockFiles.Count == 0)
{
return RubyLockData.Empty;
}
var manifestCache = new Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>>(StringComparer.OrdinalIgnoreCase);
var entries = new List<RubyLockEntry>();
var bundlerVersions = new SortedSet<string>(StringComparer.Ordinal);
foreach (var lockFile in lockFiles)
{
cancellationToken.ThrowIfCancellationRequested();
var parserResult = await ParseLockFileAsync(lockFile, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(parserResult.BundledWith))
{
bundlerVersions.Add(parserResult.BundledWith);
}
var manifestGroups = ResolveManifestGroups(lockFile, bundlerConfig, manifestCache);
var relativeLockPath = context.GetRelativePath(lockFile);
foreach (var spec in parserResult.Entries)
{
var groups = ResolveGroups(spec.Name, manifestGroups);
entries.Add(new RubyLockEntry(
spec.Name,
spec.Version,
spec.Source,
spec.Platform,
groups,
relativeLockPath));
}
}
var bundledWith = bundlerVersions.Count == 0 ? string.Empty : bundlerVersions.First();
return RubyLockData.Create(entries, bundledWith);
}
private static async ValueTask<RubyLockParserResult> ParseLockFileAsync(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return RubyLockParser.Parse(content);
}
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ResolveManifestGroups(
string lockFilePath,
RubyBundlerConfig bundlerConfig,
Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>> manifestCache)
{
var directory = Path.GetDirectoryName(lockFilePath) ?? string.Empty;
foreach (var manifestName in ManifestFileNames)
{
var candidate = Path.Combine(directory, manifestName);
if (File.Exists(candidate))
{
return GetManifestGroups(candidate, manifestCache);
}
}
foreach (var overridePath in bundlerConfig.Gemfiles)
{
if (!IsSameDirectory(directory, overridePath))
{
continue;
}
return GetManifestGroups(overridePath, manifestCache);
}
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
}
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> GetManifestGroups(
string manifestPath,
Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>> manifestCache)
{
if (manifestCache.TryGetValue(manifestPath, out var cached))
{
return cached;
}
var groups = RubyManifestParser.ParseGroups(manifestPath);
manifestCache[manifestPath] = groups;
return groups;
}
private static IReadOnlyCollection<string> ResolveGroups(
string gemName,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> manifestGroups)
{
if (manifestGroups.TryGetValue(gemName, out var groups) && groups.Count > 0)
{
return groups;
}
return DefaultGroups;
}
private static IReadOnlyCollection<string> DiscoverLockFiles(string rootPath, RubyBundlerConfig bundlerConfig)
{
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath));
void TryAdd(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return;
}
if (!TryNormalizeUnderRoot(normalizedRoot, candidate, out var normalized))
{
return;
}
if (File.Exists(normalized))
{
discovered.Add(normalized);
}
}
foreach (var name in LockFileNames)
{
TryAdd(Path.Combine(rootPath, name));
}
foreach (var gemfile in bundlerConfig.Gemfiles)
{
var directory = Path.GetDirectoryName(gemfile);
if (string.IsNullOrWhiteSpace(directory))
{
continue;
}
foreach (var name in LockFileNames)
{
TryAdd(Path.Combine(directory, name));
}
}
foreach (var candidate in EnumerateLockFiles(rootPath))
{
TryAdd(candidate);
}
return discovered
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> EnumerateLockFiles(string rootPath)
{
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath));
var pending = new Stack<(string Path, int Depth)>();
pending.Push((normalizedRoot, 0));
while (pending.Count > 0)
{
var (current, depth) = pending.Pop();
IEnumerable<string>? directories = null;
foreach (var name in LockFileNames)
{
var candidate = Path.Combine(current, name);
if (File.Exists(candidate))
{
yield return candidate;
}
}
if (depth >= MaxDiscoveryDepth)
{
continue;
}
try
{
directories = Directory.EnumerateDirectories(current);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (directories is null)
{
continue;
}
foreach (var directory in directories)
{
if (!TryNormalizeUnderRoot(normalizedRoot, directory, out var normalizedDirectory))
{
continue;
}
if (ShouldSkipDirectory(normalizedRoot, normalizedDirectory))
{
continue;
}
pending.Push((normalizedDirectory, depth + 1));
}
}
}
private static bool ShouldSkipDirectory(string rootPath, string normalizedDirectory)
{
var relative = Path.GetRelativePath(rootPath, normalizedDirectory)
.Replace('\\', '/');
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
return segments.Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase));
}
private static bool TryNormalizeUnderRoot(string normalizedRoot, string path, out string normalizedPath)
{
normalizedPath = Path.GetFullPath(path);
if (!normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
private static string EnsureTrailingSeparator(string path)
{
if (path.EndsWith(Path.DirectorySeparatorChar))
{
return path;
}
return path + Path.DirectorySeparatorChar;
}
private static bool IsSameDirectory(string lockDirectory, string manifestPath)
{
var manifestDirectory = Path.GetDirectoryName(manifestPath);
if (string.IsNullOrWhiteSpace(manifestDirectory))
{
return false;
}
return string.Equals(
Path.GetFullPath(lockDirectory),
Path.GetFullPath(manifestDirectory),
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
}

View File

@@ -2,38 +2,33 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyLockData
{
private RubyLockData(string? lockFilePath, IReadOnlyList<RubyLockEntry> entries, string bundledWith)
private RubyLockData(IReadOnlyList<RubyLockEntry> entries, string bundledWith)
{
LockFilePath = lockFilePath;
Entries = entries;
BundledWith = bundledWith;
BundledWith = bundledWith ?? string.Empty;
}
public string? LockFilePath { get; }
public string BundledWith { get; }
public IReadOnlyList<RubyLockEntry> Entries { get; }
public string BundledWith { get; }
public bool IsEmpty => Entries.Count == 0;
public static async ValueTask<RubyLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
public static ValueTask<RubyLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(rootPath);
ArgumentNullException.ThrowIfNull(context);
return RubyLockCollector.LoadAsync(context, cancellationToken);
}
var lockPath = Path.Combine(rootPath, "Gemfile.lock");
if (!File.Exists(lockPath))
public static RubyLockData Create(IReadOnlyList<RubyLockEntry> entries, string bundledWith)
{
if (entries.Count == 0 && string.IsNullOrWhiteSpace(bundledWith))
{
return Empty;
}
await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var parser = RubyLockParser.Parse(content);
return new RubyLockData(lockPath, parser.Entries, parser.BundledWith);
return new RubyLockData(entries, bundledWith);
}
public static RubyLockData Empty { get; } = new(lockFilePath: null, Array.Empty<RubyLockEntry>(), bundledWith: string.Empty);
public static RubyLockData Empty { get; } = new(Array.Empty<RubyLockEntry>(), string.Empty);
}

View File

@@ -5,4 +5,5 @@ internal sealed record RubyLockEntry(
string Version,
string Source,
string? Platform,
IReadOnlyCollection<string> Groups);
IReadOnlyCollection<string> Groups,
string LockFileRelativePath);

View File

@@ -1,52 +1,58 @@
using System.IO;
using System.Text.RegularExpressions;
using System.IO;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyLockParser
{
private static readonly Regex SpecLineRegex = new(@"^\s{4}([^\s]+)\s\(([^)]+)\)", RegexOptions.Compiled);
private enum RubyLockSection
{
None,
Gem,
Git,
Path,
BundledWith
}
private static readonly Regex SpecLineRegex = new(@"^\s{4}(?<name>[^\s]+)\s\((?<version>[^)]+)\)", RegexOptions.Compiled);
public static RubyLockParserResult Parse(string contents)
{
if (string.IsNullOrWhiteSpace(contents))
{
return new RubyLockParserResult(Array.Empty<RubyLockEntry>(), string.Empty);
return new RubyLockParserResult(Array.Empty<RubyLockParserEntry>(), string.Empty);
}
var entries = new List<RubyLockEntry>();
var entries = new List<RubyLockParserEntry>();
var section = RubyLockSection.None;
var bundledWith = string.Empty;
var inSpecs = false;
string? currentRemote = null;
string? currentRevision = null;
string? currentPath = null;
using var reader = new StringReader(contents);
string? line;
string currentSection = string.Empty;
string? currentSource = null;
bool inSpecs = false;
var bundledWith = string.Empty;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
if (line.Length == 0)
{
continue;
}
if (!char.IsWhiteSpace(line[0]))
{
currentSection = line.Trim();
section = ParseSection(line.Trim());
inSpecs = false;
currentRemote = null;
currentRevision = null;
currentPath = null;
if (string.Equals(currentSection, "GEM", StringComparison.OrdinalIgnoreCase))
if (section == RubyLockSection.Gem)
{
currentSource = "rubygems";
currentRemote = "https://rubygems.org/";
}
else if (string.Equals(currentSection, "GIT", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "BUNDLED WITH", StringComparison.OrdinalIgnoreCase))
else if (section == RubyLockSection.BundledWith)
{
var versionLine = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(versionLine))
@@ -58,72 +64,144 @@ internal static class RubyLockParser
continue;
}
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
switch (section)
{
currentSource = line[9..].Trim();
continue;
case RubyLockSection.Gem:
case RubyLockSection.Git:
case RubyLockSection.Path:
ProcessSectionLine(
line,
section,
ref inSpecs,
ref currentRemote,
ref currentRevision,
ref currentPath,
entries);
break;
default:
break;
}
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("GIT", StringComparison.OrdinalIgnoreCase)
&& currentSource is not null)
{
currentSource = $"{currentSource}@{line[10..].Trim()}";
continue;
}
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = $"path:{line[6..].Trim()}";
continue;
}
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
{
inSpecs = true;
continue;
}
if (!inSpecs)
{
continue;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
continue;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
{
continue;
}
var name = match.Groups[1].Value.Trim();
var versionToken = match.Groups[2].Value.Trim();
string version;
string? platform = null;
var tokens = versionToken.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length > 1)
{
version = tokens[0];
platform = string.Join(" ", tokens.Skip(1));
}
else
{
version = versionToken;
}
var source = currentSource ?? "unknown";
entries.Add(new RubyLockEntry(name, version, source, platform, Array.Empty<string>()));
}
return new RubyLockParserResult(entries, bundledWith);
}
private static void ProcessSectionLine(
string line,
RubyLockSection section,
ref bool inSpecs,
ref string? currentRemote,
ref string? currentRevision,
ref string? currentPath,
List<RubyLockParserEntry> entries)
{
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
{
currentRemote = line[9..].Trim();
return;
}
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase))
{
currentRevision = line[10..].Trim();
return;
}
if (line.StartsWith(" ref:", StringComparison.OrdinalIgnoreCase) && currentRevision is null)
{
currentRevision = line[6..].Trim();
return;
}
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase))
{
currentPath = line[6..].Trim();
return;
}
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
{
inSpecs = true;
return;
}
if (!inSpecs)
{
return;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
return;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
{
// Nested dependency entry under a spec.
return;
}
var name = match.Groups["name"].Value.Trim();
if (string.IsNullOrEmpty(name))
{
return;
}
var (version, platform) = ParseVersion(match.Groups["version"].Value);
var source = ResolveSource(section, currentRemote, currentRevision, currentPath);
entries.Add(new RubyLockParserEntry(name, version, source, platform));
}
private static RubyLockSection ParseSection(string value)
{
return value switch
{
"GEM" => RubyLockSection.Gem,
"GIT" => RubyLockSection.Git,
"PATH" => RubyLockSection.Path,
"BUNDLED WITH" => RubyLockSection.BundledWith,
_ => RubyLockSection.None,
};
}
private static (string Version, string? Platform) ParseVersion(string raw)
{
var trimmed = raw.Trim();
if (trimmed.Length == 0)
{
return (string.Empty, null);
}
var tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length <= 1)
{
return (trimmed, null);
}
var version = tokens[0];
var platform = string.Join(' ', tokens[1..]);
return (version, platform);
}
private static string ResolveSource(RubyLockSection section, string? remote, string? revision, string? path)
{
return section switch
{
RubyLockSection.Git when !string.IsNullOrWhiteSpace(remote) && !string.IsNullOrWhiteSpace(revision)
=> $"git:{remote}@{revision}",
RubyLockSection.Git when !string.IsNullOrWhiteSpace(remote)
=> $"git:{remote}",
RubyLockSection.Path when !string.IsNullOrWhiteSpace(path)
=> $"path:{path}",
_ when !string.IsNullOrWhiteSpace(remote)
=> remote!,
_ => "rubygems",
};
}
}
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockEntry> Entries, string BundledWith);
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockParserEntry> Entries, string BundledWith);

View File

@@ -0,0 +1,267 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyManifestParser
{
private static readonly Regex GemLineRegex = new(@"^\s*gem\s+(?<quote>['""])(?<name>[^'""]+)\k<quote>(?<args>.*)$", RegexOptions.Compiled);
private static readonly Regex GroupStartRegex = new(@"^\s*group\s+(?<args>.+?)\s+do\b", RegexOptions.Compiled);
private static readonly Regex InlineGroupRegex = new(@"(group|groups)\s*[:=]>\s*(?<value>\[.*?\]|:[A-Za-z0-9_]+)|(group|groups):\s*(?<typed>\[.*?\]|:[A-Za-z0-9_]+)", RegexOptions.Compiled);
private static readonly Regex SymbolRegex = new(@":(?<symbol>[A-Za-z0-9_]+)", RegexOptions.Compiled);
private static readonly Regex StringRegex = new(@"['""](?<value>[A-Za-z0-9_\-]+)['""]", RegexOptions.Compiled);
public static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ParseGroups(string manifestPath)
{
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
{
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
}
try
{
return ParseInternal(manifestPath);
}
catch (IOException)
{
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
}
catch (UnauthorizedAccessException)
{
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
}
}
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ParseInternal(string manifestPath)
{
var groupStack = new Stack<HashSet<string>>();
var mapping = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in File.ReadLines(manifestPath))
{
var line = StripComment(rawLine);
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (TryBeginGroup(line, groupStack))
{
continue;
}
if (IsGroupEnd(line) && groupStack.Count > 0)
{
groupStack.Pop();
continue;
}
if (!TryParseGem(line, out var gemName, out var inlineGroups))
{
continue;
}
if (!mapping.TryGetValue(gemName, out var groups))
{
groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
mapping[gemName] = groups;
}
if (groupStack.Count > 0)
{
foreach (var group in groupStack.Peek())
{
groups.Add(group);
}
}
foreach (var inline in inlineGroups)
{
groups.Add(inline);
}
if (groups.Count == 0)
{
groups.Add("default");
}
}
return mapping.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyCollection<string>)pair.Value
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray(),
StringComparer.OrdinalIgnoreCase);
}
private static bool TryBeginGroup(string line, Stack<HashSet<string>> stack)
{
var match = GroupStartRegex.Match(line);
if (!match.Success)
{
return false;
}
var parsedGroups = ParseGroupTokens(match.Groups["args"].Value);
var inherited = stack.Count > 0
? new HashSet<string>(stack.Peek(), StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (parsedGroups.Count == 0 && inherited.Count == 0)
{
inherited.Add("default");
}
foreach (var group in parsedGroups)
{
inherited.Add(group);
}
stack.Push(inherited);
return true;
}
private static IReadOnlyCollection<string> ParseGroupTokens(string tokens)
{
if (string.IsNullOrWhiteSpace(tokens))
{
return Array.Empty<string>();
}
var normalized = tokens.Trim().Trim('(', ')');
var results = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var segment in normalized.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var token = segment.Trim();
if (token.Length == 0)
{
continue;
}
var symbolMatch = SymbolRegex.Match(token);
if (symbolMatch.Success)
{
results.Add(symbolMatch.Groups["symbol"].Value);
continue;
}
var stringMatch = StringRegex.Match(token);
if (stringMatch.Success)
{
results.Add(stringMatch.Groups["value"].Value);
}
}
return results;
}
private static bool TryParseGem(string line, out string name, out IReadOnlyCollection<string> inlineGroups)
{
var match = GemLineRegex.Match(line);
if (!match.Success)
{
name = string.Empty;
inlineGroups = Array.Empty<string>();
return false;
}
name = match.Groups["name"].Value.Trim();
inlineGroups = ExtractInlineGroups(match.Groups["args"].Value);
return !string.IsNullOrWhiteSpace(name);
}
private static IReadOnlyCollection<string> ExtractInlineGroups(string args)
{
if (string.IsNullOrWhiteSpace(args))
{
return Array.Empty<string>();
}
var groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in InlineGroupRegex.Matches(args))
{
if (!match.Success)
{
continue;
}
var valueGroup = match.Groups["value"].Success ? match.Groups["value"] : match.Groups["typed"];
if (!valueGroup.Success)
{
continue;
}
var value = valueGroup.Value;
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (Match symbol in SymbolRegex.Matches(value))
{
if (symbol.Success)
{
groups.Add(symbol.Groups["symbol"].Value);
}
}
foreach (Match str in StringRegex.Matches(value))
{
if (str.Success)
{
groups.Add(str.Groups["value"].Value);
}
}
}
return groups;
}
private static bool IsGroupEnd(string line)
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("end", StringComparison.Ordinal))
{
return false;
}
return trimmed.Length == 3 || char.IsWhiteSpace(trimmed[3]) || trimmed[3] == '#';
}
private static string StripComment(string line)
{
if (string.IsNullOrEmpty(line))
{
return string.Empty;
}
var builder = new StringBuilder(line.Length);
var inSingle = false;
var inDouble = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (ch == '\'' && !inDouble)
{
inSingle = !inSingle;
}
else if (ch == '"' && !inSingle)
{
inDouble = !inDouble;
}
if (ch == '#' && !inSingle && !inDouble)
{
break;
}
builder.Append(ch);
}
return builder.ToString();
}
}

View File

@@ -2,13 +2,17 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyPackage
{
private RubyPackage(
public static IReadOnlyCollection<string> DefaultGroups { get; } = new[] { "default" };
internal RubyPackage(
string name,
string version,
string source,
string? platform,
IReadOnlyCollection<string> groups,
string locator,
string? lockfileLocator,
string? artifactLocator,
string evidenceSource,
bool declaredOnly)
{
Name = name;
@@ -16,7 +20,9 @@ internal sealed class RubyPackage
Source = source;
Platform = platform;
Groups = groups;
Locator = locator;
LockfileLocator = string.IsNullOrWhiteSpace(lockfileLocator) ? null : Normalize(lockfileLocator);
ArtifactLocator = string.IsNullOrWhiteSpace(artifactLocator) ? null : Normalize(artifactLocator);
EvidenceSource = string.IsNullOrWhiteSpace(evidenceSource) ? "Gemfile.lock" : evidenceSource;
DeclaredOnly = declaredOnly;
}
@@ -30,7 +36,11 @@ internal sealed class RubyPackage
public IReadOnlyCollection<string> Groups { get; }
public string Locator { get; }
public string? LockfileLocator { get; }
public string? ArtifactLocator { get; }
public string EvidenceSource { get; }
public bool DeclaredOnly { get; }
@@ -38,15 +48,31 @@ internal sealed class RubyPackage
public string ComponentKey => $"purl::{Purl}";
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(RubyCapabilities? capabilities = null, RubyRuntimeUsage? runtimeUsage = null)
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(
RubyCapabilities? capabilities = null,
RubyRuntimeUsage? runtimeUsage = null)
{
var metadata = new List<KeyValuePair<string, string?>>
{
new("source", Source),
new("lockfile", string.IsNullOrWhiteSpace(Locator) ? "Gemfile.lock" : Locator),
new("declaredOnly", DeclaredOnly ? "true" : "false")
};
if (!string.IsNullOrWhiteSpace(LockfileLocator))
{
metadata.Add(new KeyValuePair<string, string?>("lockfile", LockfileLocator));
}
else if (!string.IsNullOrWhiteSpace(ArtifactLocator))
{
metadata.Add(new KeyValuePair<string, string?>("lockfile", ArtifactLocator));
}
if (!string.IsNullOrWhiteSpace(ArtifactLocator)
&& !string.Equals(ArtifactLocator, LockfileLocator, StringComparison.OrdinalIgnoreCase))
{
metadata.Add(new KeyValuePair<string, string?>("artifact", ArtifactLocator));
}
if (!string.IsNullOrWhiteSpace(Platform))
{
metadata.Add(new KeyValuePair<string, string?>("platform", Platform));
@@ -81,8 +107,7 @@ internal sealed class RubyPackage
foreach (var scheduler in schedulers)
{
var key = $"capability.scheduler.{scheduler}";
metadata.Add(new KeyValuePair<string, string?>(key, "true"));
metadata.Add(new KeyValuePair<string, string?>($"capability.scheduler.{scheduler}", "true"));
}
}
}
@@ -111,32 +136,19 @@ internal sealed class RubyPackage
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
{
var locator = string.IsNullOrWhiteSpace(Locator)
? "Gemfile.lock"
: Locator;
var locator = ArtifactLocator ?? LockfileLocator ?? "Gemfile.lock";
return new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"Gemfile.lock",
EvidenceSource,
locator,
Value: null,
Sha256: null)
};
}
public static RubyPackage From(RubyLockEntry entry, string lockFileRelativePath)
{
var groups = entry.Groups.Count == 0
? Array.Empty<string>()
: entry.Groups.OrderBy(static g => g, StringComparer.OrdinalIgnoreCase).ToArray();
return new RubyPackage(entry.Name, entry.Version, entry.Source, entry.Platform, groups, lockFileRelativePath, declaredOnly: true);
}
public static RubyPackage FromVendor(string name, string version, string source, string? platform, string locator)
{
return new RubyPackage(name, version, source, platform, Array.Empty<string>(), locator, declaredOnly: true);
}
private static string Normalize(string path)
=> path.Replace('\\', '/');
}

View File

@@ -2,104 +2,140 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyPackageCollector
{
public static IReadOnlyList<RubyPackage> CollectPackages(RubyLockData lockData, LanguageAnalyzerContext context)
public static IReadOnlyList<RubyPackage> CollectPackages(
RubyLockData lockData,
LanguageAnalyzerContext context,
CancellationToken cancellationToken)
{
var packages = new List<RubyPackage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var bundlerConfig = RubyBundlerConfig.Load(context.RootPath);
var vendorArtifacts = RubyVendorArtifactCollector.Collect(context, bundlerConfig, cancellationToken);
var builders = new Dictionary<string, RubyPackageBuilder>(StringComparer.OrdinalIgnoreCase);
if (!lockData.IsEmpty)
foreach (var artifact in vendorArtifacts)
{
var relativeLockPath = lockData.LockFilePath is null
? "Gemfile.lock"
: context.GetRelativePath(lockData.LockFilePath);
if (string.IsNullOrWhiteSpace(relativeLockPath))
cancellationToken.ThrowIfCancellationRequested();
var key = RubyPackageBuilder.BuildKey(artifact.Name, artifact.Version, artifact.Platform);
if (!builders.TryGetValue(key, out var builder))
{
relativeLockPath = "Gemfile.lock";
builder = new RubyPackageBuilder(artifact.Name, artifact.Version, artifact.Platform);
builders[key] = builder;
}
foreach (var entry in lockData.Entries)
{
var key = $"{entry.Name}@{entry.Version}";
if (!seen.Add(key))
{
continue;
}
packages.Add(RubyPackage.From(entry, relativeLockPath));
}
builder.ApplyVendorArtifact(artifact);
}
CollectVendorCachePackages(context, packages, seen);
return packages;
}
private static void CollectVendorCachePackages(LanguageAnalyzerContext context, List<RubyPackage> packages, HashSet<string> seen)
{
var vendorCache = Path.Combine(context.RootPath, "vendor", "cache");
if (!Directory.Exists(vendorCache))
foreach (var entry in lockData.Entries)
{
return;
}
foreach (var gemPath in Directory.EnumerateFiles(vendorCache, "*.gem", SearchOption.AllDirectories))
{
if (!TryParseGemArchive(gemPath, out var name, out var version, out var platform))
cancellationToken.ThrowIfCancellationRequested();
var key = RubyPackageBuilder.BuildKey(entry.Name, entry.Version, entry.Platform);
if (!builders.TryGetValue(key, out var builder))
{
continue;
builder = new RubyPackageBuilder(entry.Name, entry.Version, entry.Platform);
builders[key] = builder;
}
var key = $"{name}@{version}";
if (!seen.Add(key))
{
continue;
}
var locator = context.GetRelativePath(gemPath);
packages.Add(RubyPackage.FromVendor(name, version, source: "vendor-cache", platform, locator));
builder.ApplyLockEntry(entry);
}
}
private static bool TryParseGemArchive(string gemPath, out string name, out string version, out string? platform)
{
name = string.Empty;
version = string.Empty;
platform = null;
var fileName = Path.GetFileNameWithoutExtension(gemPath);
if (string.IsNullOrWhiteSpace(fileName))
if (builders.Count == 0)
{
return false;
return Array.Empty<RubyPackage>();
}
var segments = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
return false;
}
var versionIndex = -1;
for (var i = 1; i < segments.Length; i++)
{
if (char.IsDigit(segments[i][0]))
{
versionIndex = i;
break;
}
}
if (versionIndex <= 0)
{
return false;
}
name = string.Join('-', segments[..versionIndex]);
version = segments[versionIndex];
platform = segments.Length > versionIndex + 1
? string.Join('-', segments[(versionIndex + 1)..])
: null;
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
return builders.Values
.Select(builder => builder.Build())
.OrderBy(static package => package.ComponentKey, StringComparer.Ordinal)
.ToArray();
}
}
internal sealed class RubyPackageBuilder
{
private readonly string _name;
private readonly string _version;
private readonly string? _platform;
private readonly HashSet<string> _groups = new(StringComparer.OrdinalIgnoreCase);
private string? _lockSource;
private string? _lockLocator;
private string? _lockEvidenceSource;
private string? _artifactSource;
private string? _artifactLocator;
private string? _artifactEvidenceSource;
private bool _hasVendor;
public RubyPackageBuilder(string name, string version, string? platform)
{
_name = name;
_version = version;
_platform = platform;
}
public static string BuildKey(string name, string version, string? platform)
{
return platform is null
? $"{name}::{version}"
: $"{name}::{version}::{platform}";
}
public void ApplyLockEntry(RubyLockEntry entry)
{
_lockSource ??= entry.Source;
_lockLocator ??= NormalizeLocator(entry.LockFileRelativePath);
_lockEvidenceSource ??= Path.GetFileName(entry.LockFileRelativePath);
foreach (var group in entry.Groups)
{
if (!string.IsNullOrWhiteSpace(group))
{
_groups.Add(group);
}
}
}
public void ApplyVendorArtifact(RubyVendorArtifact artifact)
{
_hasVendor = true;
_artifactLocator ??= NormalizeLocator(artifact.RelativePath);
_artifactSource ??= artifact.SourceLabel;
_artifactEvidenceSource ??= Path.GetFileName(artifact.RelativePath);
}
public RubyPackage Build()
{
var groups = _groups.Count == 0
? RubyPackage.DefaultGroups
: _groups
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
var source = _lockSource ?? _artifactSource ?? "unknown";
var evidenceSource = _hasVendor
? _artifactEvidenceSource ?? "vendor"
: _lockEvidenceSource ?? "Gemfile.lock";
return new RubyPackage(
_name,
_version,
source,
_platform,
groups,
lockfileLocator: _lockLocator,
artifactLocator: _artifactLocator,
evidenceSource,
declaredOnly: !_hasVendor);
}
private static string NormalizeLocator(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return path.Replace('\\', '/');
}
}

View File

@@ -0,0 +1,290 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyVendorArtifactCollector
{
private static readonly string[] DefaultVendorRoots =
{
Path.Combine("vendor", "cache"),
Path.Combine(".bundle", "cache")
};
private static readonly string[] DirectoryBlockList =
{
".git",
".hg",
".svn",
"node_modules",
"tmp",
"log",
"coverage"
};
public static IReadOnlyList<RubyVendorArtifact> Collect(LanguageAnalyzerContext context, RubyBundlerConfig config, CancellationToken cancellationToken)
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(context.RootPath));
void TryAdd(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var absolute = Path.IsPathRooted(path) ? path : Path.Combine(context.RootPath, path);
try
{
absolute = Path.GetFullPath(absolute);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
return;
}
if (!absolute.StartsWith(normalizedRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
{
return;
}
if (Directory.Exists(absolute))
{
roots.Add(absolute);
}
}
foreach (var root in DefaultVendorRoots)
{
TryAdd(root);
}
TryAdd(Path.Combine(context.RootPath, "vendor", "bundle"));
foreach (var bundlePath in config.BundlePaths)
{
TryAdd(bundlePath);
TryAdd(Path.Combine(bundlePath, "cache"));
}
var artifacts = new List<RubyVendorArtifact>();
foreach (var root in roots.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))
{
TraverseDirectory(context, root, artifacts, cancellationToken);
}
return artifacts;
}
private static void TraverseDirectory(
LanguageAnalyzerContext context,
string root,
List<RubyVendorArtifact> artifacts,
CancellationToken cancellationToken)
{
var stack = new Stack<string>();
stack.Push(root);
while (stack.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var current = stack.Pop();
IEnumerable<string>? files = null;
try
{
files = Directory.EnumerateFiles(current);
}
catch (IOException)
{
files = null;
}
catch (UnauthorizedAccessException)
{
files = null;
}
if (files is not null)
{
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessFile(context, artifacts, file);
}
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(current);
}
catch (IOException)
{
directories = null;
}
catch (UnauthorizedAccessException)
{
directories = null;
}
if (directories is null)
{
continue;
}
foreach (var directory in directories)
{
cancellationToken.ThrowIfCancellationRequested();
if (ShouldSkip(directory))
{
continue;
}
ProcessDirectoryArtifact(context, artifacts, directory);
stack.Push(directory);
}
}
}
private static void ProcessFile(LanguageAnalyzerContext context, List<RubyVendorArtifact> artifacts, string filePath)
{
var extension = Path.GetExtension(filePath);
if (!extension.Equals(".gem", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".gemspec", StringComparison.OrdinalIgnoreCase))
{
return;
}
var fileName = Path.GetFileNameWithoutExtension(filePath);
if (!RubyPackageNameParser.TryParse(fileName, out var name, out var version, out var platform))
{
return;
}
if (!TryGetRelativePath(context, filePath, out var relative))
{
return;
}
var sourceLabel = DescribeSource(relative);
artifacts.Add(new RubyVendorArtifact(name, version, platform, relative, sourceLabel));
}
private static void ProcessDirectoryArtifact(LanguageAnalyzerContext context, List<RubyVendorArtifact> artifacts, string directoryPath)
{
var parent = Path.GetFileName(Path.GetDirectoryName(directoryPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) ?? string.Empty);
if (!parent.Equals("gems", StringComparison.OrdinalIgnoreCase))
{
return;
}
var directoryName = Path.GetFileName(directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (!RubyPackageNameParser.TryParse(directoryName, out var name, out var version, out var platform))
{
return;
}
if (!TryGetRelativePath(context, directoryPath, out var relative))
{
return;
}
var sourceLabel = DescribeSource(relative);
artifacts.Add(new RubyVendorArtifact(name, version, platform, relative, sourceLabel));
}
private static bool TryGetRelativePath(LanguageAnalyzerContext context, string absolutePath, out string relative)
{
relative = context.GetRelativePath(absolutePath);
if (string.IsNullOrWhiteSpace(relative) || relative.StartsWith("..", StringComparison.Ordinal))
{
return false;
}
relative = relative.Replace('\\', '/');
return true;
}
private static bool ShouldSkip(string directoryPath)
{
var name = Path.GetFileName(directoryPath);
if (string.IsNullOrEmpty(name))
{
return false;
}
return DirectoryBlockList.Contains(name, StringComparer.OrdinalIgnoreCase);
}
private static string DescribeSource(string relativePath)
{
var normalized = relativePath.Replace('\\', '/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return normalized;
}
return segments[0];
}
private static string EnsureTrailingSeparator(string path)
{
if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar))
{
return path;
}
return path + Path.DirectorySeparatorChar;
}
}
internal sealed record RubyVendorArtifact(
string Name,
string Version,
string? Platform,
string RelativePath,
string SourceLabel);
internal static class RubyPackageNameParser
{
public static bool TryParse(string value, out string name, out string version, out string? platform)
{
name = string.Empty;
version = string.Empty;
platform = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var segments = value.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
return false;
}
var versionIndex = -1;
for (var i = segments.Length - 1; i >= 0; i--)
{
if (char.IsDigit(segments[i][0]))
{
versionIndex = i;
break;
}
}
if (versionIndex <= 0)
{
return false;
}
name = string.Join('-', segments[..versionIndex]);
version = segments[versionIndex];
platform = versionIndex < segments.Length - 1
? string.Join('-', segments[(versionIndex + 1)..])
: null;
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
}
}

View File

@@ -1,4 +1,6 @@
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Validation;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
@@ -13,14 +15,16 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var lockData = await RubyLockData.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
if (lockData.IsEmpty)
await EnsureSurfaceValidationAsync(context, cancellationToken).ConfigureAwait(false);
var lockData = await RubyLockData.LoadAsync(context, cancellationToken).ConfigureAwait(false);
var packages = RubyPackageCollector.CollectPackages(lockData, context, cancellationToken);
if (packages.Count == 0)
{
return;
}
var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false);
var packages = RubyPackageCollector.CollectPackages(lockData, context);
var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false);
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
@@ -40,4 +44,32 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
usedByEntrypoint: runtimeUsage?.UsedByEntrypoint ?? false);
}
}
private static async ValueTask EnsureSurfaceValidationAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
if (context.Services is null)
{
return;
}
if (!context.TryGetService<ISurfaceValidatorRunner>(out var validatorRunner)
|| !context.TryGetService<ISurfaceEnvironment>(out var environment))
{
return;
}
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["analyzerId"] = \"ruby\",
[\"rootPath\"] = context.RootPath
};
var validationContext = SurfaceValidationContext.Create(
context.Services,
\"StellaOps.Scanner.Analyzers.Lang.Ruby\",
environment.Settings,
properties);
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -2,5 +2,6 @@
| Task ID | State | Notes |
| --- | --- | --- |
| `SCANNER-ENG-0016` | DOING (2025-11-10) | Building RubyLockCollector + multi-source vendor ingestion per design §4.14.3 (Codex agent). |
| `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. |