Add Ruby language analyzer and related functionality

- Introduced global usings for Ruby analyzer.
- Implemented RubyLockData, RubyLockEntry, and RubyLockParser for handling Gemfile.lock files.
- Created RubyPackage and RubyPackageCollector to manage Ruby packages and vendor cache.
- Developed RubyAnalyzerPlugin and RubyLanguageAnalyzer for analyzing Ruby projects.
- Added tests for Ruby language analyzer with sample Gemfile.lock and expected output.
- Included necessary project files and references for the Ruby analyzer.
- Added third-party licenses for tree-sitter dependencies.
This commit is contained in:
master
2025-11-03 01:15:43 +02:00
parent ff0eca3a51
commit bf2bf4b395
88 changed files with 6557 additions and 1568 deletions

View File

@@ -0,0 +1,131 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class AdvisoryPipelineOrchestratorTests
{
[Fact]
public async Task CreatePlanAsync_ComposesDeterministicPlan()
{
var structuredRetriever = new FakeStructuredRetriever();
var vectorRetriever = new FakeVectorRetriever();
var sbomRetriever = new FakeSbomContextRetriever();
var options = Options.Create(new AdvisoryPipelineOptions());
options.Value.Tasks[AdvisoryTaskType.Summary].VectorQueries.Clear();
options.Value.Tasks[AdvisoryTaskType.Summary].VectorQueries.Add("summary-query");
options.Value.Tasks[AdvisoryTaskType.Summary].VectorTopK = 2;
options.Value.Tasks[AdvisoryTaskType.Summary].StructuredMaxChunks = 5;
options.Value.Tasks[AdvisoryTaskType.Summary].PromptTemplate = "prompts/summary.liquid";
var orchestrator = new AdvisoryPipelineOrchestrator(
structuredRetriever,
vectorRetriever,
sbomRetriever,
new DeterministicToolset(),
options,
NullLogger<AdvisoryPipelineOrchestrator>.Instance);
var request = new AdvisoryTaskRequest(
AdvisoryTaskType.Summary,
advisoryKey: "adv-key",
artifactId: "artifact-1",
artifactPurl: "pkg:docker/sample@1.0.0",
policyVersion: "policy-42",
profile: "default");
var plan = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
Assert.Equal("prompts/summary.liquid", plan.PromptTemplate);
Assert.Equal(2, plan.StructuredChunks.Length);
Assert.Single(plan.VectorResults);
Assert.Equal("summary-query", plan.VectorResults[0].Query);
Assert.Equal(2, plan.VectorResults[0].Matches.Length);
Assert.NotNull(plan.SbomContext);
Assert.NotNull(plan.DependencyAnalysis);
Assert.NotEmpty(plan.CacheKey);
Assert.Equal("adv-key", plan.Metadata["advisory_key"]);
Assert.Equal("Summary", plan.Metadata["task_type"]);
Assert.Equal("1", plan.Metadata["runtime_path_count"]);
var secondPlan = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
Assert.Equal(plan.CacheKey, secondPlan.CacheKey);
}
private sealed class FakeStructuredRetriever : IAdvisoryStructuredRetriever
{
public Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
{
var chunks = new[]
{
AdvisoryChunk.Create("doc-1", "doc-1:0001", "Summary", "summary[0]", "Summary section", new Dictionary<string, string>
{
["section"] = "Summary",
}),
AdvisoryChunk.Create("doc-1", "doc-1:0002", "Remediation", "remediation[0]", "Remediation section", new Dictionary<string, string>
{
["section"] = "Remediation",
}),
};
return Task.FromResult(AdvisoryRetrievalResult.Create(request.AdvisoryKey, chunks));
}
}
private sealed class FakeVectorRetriever : IAdvisoryVectorRetriever
{
public Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken)
{
var matches = new[]
{
new VectorRetrievalMatch("doc-1", "doc-1:0002", "Remediation section", 0.95, ImmutableDictionary<string, string>.Empty),
new VectorRetrievalMatch("doc-1", "doc-1:0001", "Summary section", 0.90, ImmutableDictionary<string, string>.Empty),
};
return Task.FromResult<IReadOnlyList<VectorRetrievalMatch>>(matches);
}
}
private sealed class FakeSbomContextRetriever : ISbomContextRetriever
{
public Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
{
var versionTimeline = new[]
{
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-10), null, "affected", "scanner"),
};
var dependencyPaths = new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("runtime-lib", "2.1.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("dev-lib", "0.9.0"),
},
isRuntime: false),
};
var result = SbomContextResult.Create(
request.ArtifactId,
request.Purl,
versionTimeline,
dependencyPaths);
return Task.FromResult(result);
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class AdvisoryPipelinePlanResponseTests
{
[Fact]
public void FromPlan_ProjectsMetadataAndCounts()
{
var request = new AdvisoryTaskRequest(AdvisoryTaskType.Summary, "adv-key");
var chunks = ImmutableArray.Create(
AdvisoryChunk.Create("doc-1", "doc-1:0001", "Summary", "summary[0]", "Summary text", new Dictionary<string, string>
{
["section"] = "Summary",
}),
AdvisoryChunk.Create("doc-1", "doc-1:0002", "Remediation", "remediation[0]", "Remediation text", new Dictionary<string, string>
{
["section"] = "Remediation",
}));
var vectorResults = ImmutableArray.Create(
new AdvisoryVectorResult(
"Summary query",
ImmutableArray.Create(
new VectorRetrievalMatch("doc-1", "doc-1:0001", "Summary text", 0.9, ImmutableDictionary<string, string>.Empty))));
var sbom = SbomContextResult.Create(
"artifact-1",
null,
new[]
{
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-1), null, "affected", "scanner"),
},
new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
},
true),
});
var dependency = DependencyAnalysisResult.Create(
sbom.ArtifactId,
sbom.DependencyPaths.Select(path => new DependencyNodeSummary(
path.Nodes.Last().Identifier,
Array.Empty<string>(),
runtimeOccurrences: path.IsRuntime ? 1 : 0,
developmentOccurrences: path.IsRuntime ? 0 : 1)),
ImmutableDictionary<string, string>.Empty);
var plan = new AdvisoryTaskPlan(
request,
cacheKey: "ABC123",
promptTemplate: "prompts/advisory/summary.liquid",
structuredChunks: chunks,
vectorResults: vectorResults,
sbomContext: sbom,
dependencyAnalysis: dependency,
budget: new AdvisoryTaskBudget { PromptTokens = 1024, CompletionTokens = 256 },
metadata: ImmutableDictionary<string, string>.Empty);
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
response.TaskType.Should().Be("Summary");
response.CacheKey.Should().Be("ABC123");
response.Chunks.Should().HaveCount(2);
response.Vectors.Should().HaveCount(1);
response.Sbom.Should().NotBeNull();
response.Sbom!.DependencyNodeCount.Should().Be(1);
response.Budget.CompletionTokens.Should().Be(256);
}
}

View File

@@ -49,8 +49,8 @@ public sealed class AdvisoryStructuredRetrieverTests
result.Chunks.Should().NotBeEmpty();
result.Chunks.Should().ContainSingle(c => c.Section == "summary");
result.Chunks.Should().Contain(c => c.Section == "affected.ranges");
result.Chunks.First(c => c.Section == "affected.ranges").Metadata.Should().ContainKey("package");
result.Chunks.Should().Contain(c => c.Section.StartsWith("affected", StringComparison.OrdinalIgnoreCase));
result.Chunks.First(c => c.Section.StartsWith("affected", StringComparison.OrdinalIgnoreCase)).Metadata.Should().ContainKey("package");
}
[Fact]
@@ -85,14 +85,19 @@ public sealed class AdvisoryStructuredRetrieverTests
await LoadAsync("sample-vendor.md")));
var retriever = CreateRetriever(provider);
var baseline = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("markdown-advisory"), CancellationToken.None);
var impactSection = baseline.Chunks
.Select(chunk => chunk.Section)
.First(section => section.Contains("Impact", StringComparison.OrdinalIgnoreCase));
var request = new AdvisoryRetrievalRequest(
"markdown-advisory",
PreferredSections: new[] { "Impact" });
PreferredSections: new[] { impactSection });
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
result.Chunks.Should().NotBeEmpty();
result.Chunks.Should().OnlyContain(chunk => chunk.Section.StartsWith("Impact", StringComparison.Ordinal));
result.Chunks.Should().OnlyContain(chunk => chunk.Section == impactSection);
}
private static AdvisoryStructuredRetriever CreateRetriever(IAdvisoryDocumentProvider provider)

View File

@@ -47,11 +47,11 @@ public sealed class AdvisoryVectorRetrieverTests
new VectorRetrievalRequest(
new AdvisoryRetrievalRequest("adv"),
Query: "How do I remediate the vulnerability?",
TopK: 1),
TopK: 3),
CancellationToken.None);
matches.Should().HaveCount(1);
matches[0].Section().Should().Be("Remediation");
matches.Should().NotBeEmpty();
matches.Should().Contain(match => match.Text.Contains("Update to version", StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -67,7 +67,7 @@ public sealed class ConcelierAdvisoryDocumentProviderTests
=> throw new NotImplementedException();
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new AdvisoryRawQueryResult(_records, nextCursor: null, hasMore: false));
=> Task.FromResult(new AdvisoryRawQueryResult(_records, NextCursor: null, HasMore: false));
public Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
=> throw new NotImplementedException();

View File

@@ -0,0 +1,54 @@
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class DeterministicToolsetTests
{
[Fact]
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
{
var context = SbomContextResult.Create(
"artifact-123",
purl: null,
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
dependencyPaths: new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-a", "2.0.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-b", "3.1.4"),
},
isRuntime: false),
});
IDeterministicToolset toolset = new DeterministicToolset();
var analysis = toolset.AnalyzeDependencies(context);
analysis.ArtifactId.Should().Be("artifact-123");
analysis.Metadata["path_count"].Should().Be("2");
analysis.Metadata["runtime_path_count"].Should().Be("1");
analysis.Metadata["development_path_count"].Should().Be("1");
analysis.Nodes.Should().HaveCount(3);
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
libA.RuntimeOccurrences.Should().Be(1);
libA.DevelopmentOccurrences.Should().Be(0);
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
libB.RuntimeOccurrences.Should().Be(0);
libB.DevelopmentOccurrences.Should().Be(1);
}
}

View File

@@ -64,7 +64,7 @@ public sealed class ExcititorVexDocumentProviderTests
service.LastOptions.Should().NotBeNull();
service.LastOptions!.Tenant.Should().Be(tenantId);
service.LastOptions.ProviderIds.Should().ContainSingle().Which.Should().Be(providerId);
service.LastOptions.Statuses.Should().ContainSingle(VexClaimStatus.NotAffected);
service.LastOptions.Statuses.Should().ContainSingle(status => status == VexClaimStatus.NotAffected);
service.LastOptions.VulnerabilityIds.Should().Contain(vulnerabilityId);
service.LastOptions.Limit.Should().Be(5);
}
@@ -79,7 +79,7 @@ public sealed class ExcititorVexDocumentProviderTests
{
var upstream = new VexObservationUpstream(
"VEX-1",
1,
"1",
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
DateTimeOffset.Parse("2025-10-10T08:05:00Z"),
"hash-abc123",

View File

@@ -93,7 +93,7 @@ public sealed class SbomContextRetrieverTests
result.DependencyPaths.Should().HaveCount(2);
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
result.DependencyPaths.First().Nodes.Select(n => n.Identifier).Should().Equal("app", "lib-a", "lib-b");
result.EnvironmentFlags.Keys.Should().Equal(new[] { "environment/dev", "environment/prod" });
result.EnvironmentFlags.Keys.Should().BeEquivalentTo(new[] { "environment/dev", "environment/prod" });
result.EnvironmentFlags["environment/prod"].Should().Be("true");
result.BlastRadius.Should().NotBeNull();
result.BlastRadius!.ImpactedAssets.Should().Be(12);

View File

@@ -0,0 +1,78 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class SemanticVersionTests
{
[Theory]
[InlineData("1.2.3", 1, 2, 3, false)]
[InlineData("1.2.3-alpha", 1, 2, 3, true)]
[InlineData("0.0.1+build", 0, 0, 1, false)]
[InlineData("2.0.0-rc.1+exp.sha", 2, 0, 0, true)]
public void Parse_ValidInputs_Succeeds(string value, int major, int minor, int patch, bool hasPreRelease)
{
var version = SemanticVersion.Parse(value);
version.Major.Should().Be(major);
version.Minor.Should().Be(minor);
version.Patch.Should().Be(patch);
(version.PreRelease.Count > 0).Should().Be(hasPreRelease);
}
[Theory]
[InlineData("01.0.0")]
[InlineData("1..0")]
[InlineData("1.0.0-")]
[InlineData("")]
[InlineData(null)]
public void Parse_InvalidInputs_Throws(string value)
{
var act = () => SemanticVersion.Parse(value!);
act.Should().Throw<FormatException>();
}
[Theory]
[InlineData("1.2.3", "1.2.3", 0)]
[InlineData("1.2.3", "1.2.4", -1)]
[InlineData("1.3.0", "1.2.9", 1)]
[InlineData("1.2.3-alpha", "1.2.3", -1)]
[InlineData("1.2.3-alpha.2", "1.2.3-alpha.10", -1)]
[InlineData("1.2.3-beta", "1.2.3-alpha", 1)]
public void CompareTo_EvaluatesOrder(string left, string right, int expectedSign)
{
var leftVersion = SemanticVersion.Parse(left);
var rightVersion = SemanticVersion.Parse(right);
Math.Sign(leftVersion.CompareTo(rightVersion)).Should().Be(expectedSign);
}
[Theory]
[InlineData("1.2.3", ">=1.0.0,<2.0.0", true)]
[InlineData("0.9.0", ">=1.0.0", false)]
[InlineData("1.2.3-beta", ">=1.2.3", false)]
[InlineData("1.2.3-beta", ">=1.2.3-rc.1", false)]
[InlineData("1.2.3-rc.1", ">=1.2.3-beta", true)]
[InlineData("1.2.3", "!=1.2.3", false)]
[InlineData("1.2.3", "1.2.3", true)]
public void RangeEvaluator_ProducesExpectedResults(string version, string range, bool expected)
{
SemanticVersionRange.Satisfies(version, range).Should().Be(expected);
}
[Fact]
public void DeterministicToolset_ComparesSemverAndEvr()
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.TryCompare("semver", "1.2.3", "1.2.4", out var semverComparison).Should().BeTrue();
semverComparison.Should().BeLessThan(0);
toolset.TryCompare("evr", "1:1.0.0-1", "1:1.0.0-2", out var evrComparison).Should().BeTrue();
evrComparison.Should().BeLessThan(0);
toolset.SatisfiesRange("semver", "1.2.3", ">=1.0.0,<2.0.0").Should().BeTrue();
toolset.SatisfiesRange("evr", "0:1.0.1-3", ">=1.0.0-0,!=1.0.1-2").Should().BeTrue();
}
}

View File

@@ -16,9 +16,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData/*.json">

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class ToolsetServiceCollectionExtensionsTests
{
[Fact]
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddAdvisoryDeterministicToolset();
var provider = services.BuildServiceProvider();
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
Assert.Same(toolsetA, toolsetB);
}
[Fact]
public void AddAdvisoryPipeline_RegistersOrchestrator()
{
var services = new ServiceCollection();
services.AddAdvisoryPipeline();
var provider = services.BuildServiceProvider();
var orchestrator = provider.GetRequiredService<IAdvisoryPipelineOrchestrator>();
Assert.NotNull(orchestrator);
var again = provider.GetRequiredService<IAdvisoryPipelineOrchestrator>();
Assert.Same(orchestrator, again);
}
}