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:
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
public static class ToolsetServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryDeterministicToolset(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.TryAddSingleton<IDeterministicToolset, DeterministicToolset>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdvisoryPipeline(this IServiceCollection services, Action<AdvisoryPipelineOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddAdvisoryDeterministicToolset();
|
||||
|
||||
var optionsBuilder = services.AddOptions<AdvisoryPipelineOptions>();
|
||||
optionsBuilder.Configure(options => options.ApplyDefaults());
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IAdvisoryPipelineOrchestrator, AdvisoryPipelineOrchestrator>();
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Queue payload sent to workers to execute a pipeline plan.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPipelineExecutionMessage
|
||||
{
|
||||
public AdvisoryPipelineExecutionMessage(
|
||||
string planCacheKey,
|
||||
AdvisoryTaskRequest request,
|
||||
IReadOnlyDictionary<string, string> planMetadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(planCacheKey);
|
||||
PlanCacheKey = planCacheKey;
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
PlanMetadata = planMetadata ?? throw new ArgumentNullException(nameof(planMetadata));
|
||||
}
|
||||
|
||||
public string PlanCacheKey { get; }
|
||||
|
||||
public AdvisoryTaskRequest Request { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> PlanMetadata { get; }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public sealed class AdvisoryPipelineOptions
|
||||
{
|
||||
private static readonly AdvisoryTaskConfiguration SummaryDefaults = new()
|
||||
{
|
||||
PromptTemplate = "prompts/advisory/summary.liquid",
|
||||
StructuredMaxChunks = 25,
|
||||
VectorTopK = 5,
|
||||
SbomMaxTimelineEntries = 10,
|
||||
SbomMaxDependencyPaths = 20,
|
||||
IncludeEnvironmentFlags = true,
|
||||
IncludeBlastRadius = true,
|
||||
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 512 },
|
||||
VectorQueries = { "Summarize key facts", "What is impacted?" },
|
||||
};
|
||||
|
||||
private static readonly AdvisoryTaskConfiguration ConflictDefaults = new()
|
||||
{
|
||||
PromptTemplate = "prompts/advisory/conflict.liquid",
|
||||
StructuredMaxChunks = 30,
|
||||
VectorTopK = 6,
|
||||
SbomMaxTimelineEntries = 8,
|
||||
SbomMaxDependencyPaths = 15,
|
||||
IncludeEnvironmentFlags = true,
|
||||
IncludeBlastRadius = false,
|
||||
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 512 },
|
||||
VectorQueries = { "Highlight conflicting statements", "Where do sources disagree?" },
|
||||
};
|
||||
|
||||
private static readonly AdvisoryTaskConfiguration RemediationDefaults = new()
|
||||
{
|
||||
PromptTemplate = "prompts/advisory/remediation.liquid",
|
||||
StructuredMaxChunks = 35,
|
||||
VectorTopK = 6,
|
||||
SbomMaxTimelineEntries = 12,
|
||||
SbomMaxDependencyPaths = 25,
|
||||
IncludeEnvironmentFlags = true,
|
||||
IncludeBlastRadius = true,
|
||||
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 640 },
|
||||
VectorQueries = { "Provide remediation steps", "Outline mitigations and fixes" },
|
||||
};
|
||||
|
||||
public IDictionary<AdvisoryTaskType, AdvisoryTaskConfiguration> Tasks { get; } =
|
||||
new Dictionary<AdvisoryTaskType, AdvisoryTaskConfiguration>
|
||||
{
|
||||
[AdvisoryTaskType.Summary] = SummaryDefaults.Clone(),
|
||||
[AdvisoryTaskType.Conflict] = ConflictDefaults.Clone(),
|
||||
[AdvisoryTaskType.Remediation] = RemediationDefaults.Clone(),
|
||||
};
|
||||
|
||||
public void ApplyDefaults()
|
||||
{
|
||||
if (!Tasks.ContainsKey(AdvisoryTaskType.Summary))
|
||||
{
|
||||
Tasks[AdvisoryTaskType.Summary] = SummaryDefaults.Clone();
|
||||
}
|
||||
|
||||
if (!Tasks.ContainsKey(AdvisoryTaskType.Conflict))
|
||||
{
|
||||
Tasks[AdvisoryTaskType.Conflict] = ConflictDefaults.Clone();
|
||||
}
|
||||
|
||||
if (!Tasks.ContainsKey(AdvisoryTaskType.Remediation))
|
||||
{
|
||||
Tasks[AdvisoryTaskType.Remediation] = RemediationDefaults.Clone();
|
||||
}
|
||||
|
||||
foreach (var entry in Tasks)
|
||||
{
|
||||
entry.Value.ApplyDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
public AdvisoryTaskConfiguration GetConfiguration(AdvisoryTaskType taskType)
|
||||
{
|
||||
ApplyDefaults();
|
||||
if (!Tasks.TryGetValue(taskType, out var configuration))
|
||||
{
|
||||
throw new InvalidOperationException($"No configuration registered for task type '{taskType}'.");
|
||||
}
|
||||
|
||||
configuration.ApplyDefaults();
|
||||
return configuration;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisoryTaskConfiguration
|
||||
{
|
||||
public string PromptTemplate { get; set; } = string.Empty;
|
||||
|
||||
public List<string> VectorQueries { get; set; } = new();
|
||||
|
||||
public int VectorTopK { get; set; }
|
||||
|
||||
public int StructuredMaxChunks { get; set; }
|
||||
|
||||
public int SbomMaxTimelineEntries { get; set; }
|
||||
|
||||
public int SbomMaxDependencyPaths { get; set; }
|
||||
|
||||
public bool IncludeEnvironmentFlags { get; set; }
|
||||
|
||||
public bool IncludeBlastRadius { get; set; }
|
||||
|
||||
public AdvisoryTaskBudget Budget { get; set; } = new();
|
||||
|
||||
internal AdvisoryTaskConfiguration Clone()
|
||||
{
|
||||
var clone = (AdvisoryTaskConfiguration)MemberwiseClone();
|
||||
clone.VectorQueries = new List<string>(VectorQueries);
|
||||
clone.Budget = new AdvisoryTaskBudget
|
||||
{
|
||||
PromptTokens = Budget.PromptTokens,
|
||||
CompletionTokens = Budget.CompletionTokens,
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
|
||||
internal void ApplyDefaults()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PromptTemplate))
|
||||
{
|
||||
PromptTemplate = "prompts/advisory/default.liquid";
|
||||
}
|
||||
|
||||
if (VectorQueries.Count == 0)
|
||||
{
|
||||
VectorQueries.Add("Provide relevant advisory details");
|
||||
}
|
||||
|
||||
if (VectorTopK <= 0)
|
||||
{
|
||||
VectorTopK = 5;
|
||||
}
|
||||
|
||||
if (StructuredMaxChunks <= 0)
|
||||
{
|
||||
StructuredMaxChunks = 20;
|
||||
}
|
||||
|
||||
if (SbomMaxTimelineEntries < 0)
|
||||
{
|
||||
SbomMaxTimelineEntries = 0;
|
||||
}
|
||||
|
||||
if (SbomMaxDependencyPaths < 0)
|
||||
{
|
||||
SbomMaxDependencyPaths = 0;
|
||||
}
|
||||
|
||||
Budget ??= new AdvisoryTaskBudget();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<string> GetVectorQueries()
|
||||
=> new ReadOnlyCollection<string>(VectorQueries);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
|
||||
{
|
||||
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
|
||||
private readonly IAdvisoryVectorRetriever _vectorRetriever;
|
||||
private readonly ISbomContextRetriever _sbomContextRetriever;
|
||||
private readonly IDeterministicToolset _toolset;
|
||||
private readonly AdvisoryPipelineOptions _options;
|
||||
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
|
||||
|
||||
public AdvisoryPipelineOrchestrator(
|
||||
IAdvisoryStructuredRetriever structuredRetriever,
|
||||
IAdvisoryVectorRetriever vectorRetriever,
|
||||
ISbomContextRetriever sbomContextRetriever,
|
||||
IDeterministicToolset toolset,
|
||||
IOptions<AdvisoryPipelineOptions> options,
|
||||
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
|
||||
{
|
||||
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
|
||||
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
|
||||
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
|
||||
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.ApplyDefaults();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var config = _options.GetConfiguration(request.TaskType);
|
||||
|
||||
var structuredRequest = new AdvisoryRetrievalRequest(
|
||||
request.AdvisoryKey,
|
||||
request.PreferredSections,
|
||||
config.StructuredMaxChunks);
|
||||
|
||||
var structured = await _structuredRetriever
|
||||
.RetrieveAsync(structuredRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false);
|
||||
var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
|
||||
var plan = new AdvisoryTaskPlan(
|
||||
request,
|
||||
cacheKey,
|
||||
config.PromptTemplate,
|
||||
structured.Chunks.ToImmutableArray(),
|
||||
vectorResults,
|
||||
sbomContext,
|
||||
dependencyAnalysis,
|
||||
config.Budget,
|
||||
metadata);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalRequest structuredRequest,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (configuration.VectorQueries.Count == 0)
|
||||
{
|
||||
return ImmutableArray<AdvisoryVectorResult>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
|
||||
foreach (var query in configuration.GetVectorQueries())
|
||||
{
|
||||
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
|
||||
var matches = await _vectorRetriever
|
||||
.SearchAsync(vectorRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
builder.Add(new AdvisoryVectorResult(query, matches.ToImmutableArray()));
|
||||
}
|
||||
|
||||
return builder.MoveToImmutable();
|
||||
}
|
||||
|
||||
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.ArtifactId))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var sbomRequest = new SbomContextRequest(
|
||||
artifactId: request.ArtifactId!,
|
||||
purl: request.ArtifactPurl,
|
||||
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
|
||||
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
|
||||
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
|
||||
includeBlastRadius: configuration.IncludeBlastRadius);
|
||||
|
||||
var context = await _sbomContextRetriever
|
||||
.RetrieveAsync(sbomRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var analysis = _toolset.AnalyzeDependencies(context);
|
||||
return (context, analysis);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalResult structured,
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
builder["task_type"] = request.TaskType.ToString();
|
||||
builder["advisory_key"] = request.AdvisoryKey;
|
||||
builder["profile"] = request.Profile;
|
||||
builder["structured_chunk_count"] = structured.Chunks.Count.ToString(CultureInfo.InvariantCulture);
|
||||
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
|
||||
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
|
||||
builder["includes_sbom"] = (sbom is not null).ToString();
|
||||
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
builder["force_refresh"] = request.ForceRefresh.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.PolicyVersion))
|
||||
{
|
||||
builder["policy_version"] = request.PolicyVersion!;
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
builder["sbom_version_count"] = sbom.VersionTimeline.Count.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string ComputeCacheKey(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalResult structured,
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(request.TaskType)
|
||||
.Append('|').Append(request.AdvisoryKey)
|
||||
.Append('|').Append(request.ArtifactId ?? string.Empty)
|
||||
.Append('|').Append(request.PolicyVersion ?? string.Empty)
|
||||
.Append('|').Append(request.Profile);
|
||||
|
||||
if (request.PreferredSections is not null)
|
||||
{
|
||||
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Append('|').Append(section);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var chunkId in structured.Chunks
|
||||
.Select(chunk => chunk.ChunkId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|chunk:").Append(chunkId);
|
||||
}
|
||||
|
||||
foreach (var vector in vectors)
|
||||
{
|
||||
builder.Append("|query:").Append(vector.Query);
|
||||
foreach (var match in vector.Matches
|
||||
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
|
||||
.ThenBy(m => m.Score))
|
||||
{
|
||||
builder.Append("|match:")
|
||||
.Append(match.ChunkId)
|
||||
.Append('@')
|
||||
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Count);
|
||||
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Count);
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|sbommeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency is not null)
|
||||
{
|
||||
foreach (var node in dependency.Nodes
|
||||
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|dep:")
|
||||
.Append(node.Identifier)
|
||||
.Append(':')
|
||||
.Append(node.RuntimeOccurrences)
|
||||
.Append(':')
|
||||
.Append(node.DevelopmentOccurrences);
|
||||
}
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// DTO exposed via service/CLI APIs describing the orchestration plan without leaking raw chunks.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPipelinePlanResponse
|
||||
{
|
||||
private AdvisoryPipelinePlanResponse(
|
||||
string taskType,
|
||||
string cacheKey,
|
||||
string promptTemplate,
|
||||
AdvisoryTaskBudget budget,
|
||||
IReadOnlyList<PipelineChunkSummary> chunks,
|
||||
IReadOnlyList<PipelineVectorSummary> vectors,
|
||||
PipelineSbomSummary? sbom,
|
||||
IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
TaskType = taskType;
|
||||
CacheKey = cacheKey;
|
||||
PromptTemplate = promptTemplate;
|
||||
Budget = budget;
|
||||
Chunks = chunks;
|
||||
Vectors = vectors;
|
||||
Sbom = sbom;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public string TaskType { get; }
|
||||
|
||||
public string CacheKey { get; }
|
||||
|
||||
public string PromptTemplate { get; }
|
||||
|
||||
public AdvisoryTaskBudget Budget { get; }
|
||||
|
||||
public IReadOnlyList<PipelineChunkSummary> Chunks { get; }
|
||||
|
||||
public IReadOnlyList<PipelineVectorSummary> Vectors { get; }
|
||||
|
||||
public PipelineSbomSummary? Sbom { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
public static AdvisoryPipelinePlanResponse FromPlan(AdvisoryTaskPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var chunkSummaries = plan.StructuredChunks
|
||||
.Select(chunk => new PipelineChunkSummary(
|
||||
chunk.DocumentId,
|
||||
chunk.ChunkId,
|
||||
chunk.Section,
|
||||
chunk.Metadata.ContainsKey("section") ? chunk.Metadata["section"] : chunk.Section))
|
||||
.ToImmutableArray();
|
||||
|
||||
var vectorSummaries = plan.VectorResults
|
||||
.Select(vector => new PipelineVectorSummary(
|
||||
vector.Query,
|
||||
vector.Matches
|
||||
.Select(match => new PipelineVectorMatchSummary(match.ChunkId, match.Score))
|
||||
.ToImmutableArray()))
|
||||
.ToImmutableArray();
|
||||
|
||||
PipelineSbomSummary? sbomSummary = null;
|
||||
if (plan.SbomContext is not null)
|
||||
{
|
||||
sbomSummary = new PipelineSbomSummary(
|
||||
plan.SbomContext.ArtifactId,
|
||||
plan.SbomContext.VersionTimeline.Count,
|
||||
plan.SbomContext.DependencyPaths.Count,
|
||||
plan.DependencyAnalysis?.Nodes.Length ?? 0);
|
||||
}
|
||||
|
||||
return new AdvisoryPipelinePlanResponse(
|
||||
plan.Request.TaskType.ToString(),
|
||||
plan.CacheKey,
|
||||
plan.PromptTemplate,
|
||||
plan.Budget,
|
||||
chunkSummaries,
|
||||
vectorSummaries,
|
||||
sbomSummary,
|
||||
plan.Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PipelineChunkSummary(
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Section,
|
||||
string DisplaySection);
|
||||
|
||||
public sealed record PipelineVectorSummary(
|
||||
string Query,
|
||||
ImmutableArray<PipelineVectorMatchSummary> Matches);
|
||||
|
||||
public sealed record PipelineVectorMatchSummary(
|
||||
string ChunkId,
|
||||
double Score);
|
||||
|
||||
public sealed record PipelineSbomSummary(
|
||||
string ArtifactId,
|
||||
int VersionTimelineCount,
|
||||
int DependencyPathCount,
|
||||
int DependencyNodeCount);
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public sealed class AdvisoryTaskPlan
|
||||
{
|
||||
public AdvisoryTaskPlan(
|
||||
AdvisoryTaskRequest request,
|
||||
string cacheKey,
|
||||
string promptTemplate,
|
||||
ImmutableArray<AdvisoryChunk> structuredChunks,
|
||||
ImmutableArray<AdvisoryVectorResult> vectorResults,
|
||||
SbomContextResult? sbomContext,
|
||||
DependencyAnalysisResult? dependencyAnalysis,
|
||||
AdvisoryTaskBudget budget,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
|
||||
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
|
||||
StructuredChunks = structuredChunks;
|
||||
VectorResults = vectorResults;
|
||||
SbomContext = sbomContext;
|
||||
DependencyAnalysis = dependencyAnalysis;
|
||||
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
}
|
||||
|
||||
public AdvisoryTaskRequest Request { get; }
|
||||
|
||||
public string CacheKey { get; }
|
||||
|
||||
public string PromptTemplate { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
|
||||
|
||||
public SbomContextResult? SbomContext { get; }
|
||||
|
||||
public DependencyAnalysisResult? DependencyAnalysis { get; }
|
||||
|
||||
public AdvisoryTaskBudget Budget { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryVectorResult
|
||||
{
|
||||
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
|
||||
{
|
||||
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
|
||||
Matches = matches;
|
||||
}
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryTaskBudget
|
||||
{
|
||||
public int PromptTokens { get; init; } = 2048;
|
||||
|
||||
public int CompletionTokens { get; init; } = 512;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public sealed class AdvisoryTaskRequest
|
||||
{
|
||||
public AdvisoryTaskRequest(
|
||||
AdvisoryTaskType taskType,
|
||||
string advisoryKey,
|
||||
string? artifactId = null,
|
||||
string? artifactPurl = null,
|
||||
string? policyVersion = null,
|
||||
string profile = "default",
|
||||
IReadOnlyCollection<string>? preferredSections = null,
|
||||
bool forceRefresh = false)
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(AdvisoryTaskType), taskType))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(taskType));
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
TaskType = taskType;
|
||||
AdvisoryKey = advisoryKey.Trim();
|
||||
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId.Trim();
|
||||
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl.Trim();
|
||||
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion.Trim();
|
||||
Profile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
|
||||
PreferredSections = preferredSections;
|
||||
ForceRefresh = forceRefresh;
|
||||
}
|
||||
|
||||
public AdvisoryTaskType TaskType { get; }
|
||||
|
||||
public string AdvisoryKey { get; }
|
||||
|
||||
public string? ArtifactId { get; }
|
||||
|
||||
public string? ArtifactPurl { get; }
|
||||
|
||||
public string? PolicyVersion { get; }
|
||||
|
||||
public string Profile { get; }
|
||||
|
||||
public IReadOnlyCollection<string>? PreferredSections { get; }
|
||||
|
||||
public bool ForceRefresh { get; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public enum AdvisoryTaskType
|
||||
{
|
||||
Summary,
|
||||
Conflict,
|
||||
Remediation,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public interface IAdvisoryPipelineOrchestrator
|
||||
{
|
||||
Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
|
||||
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-003 | DOING | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | DOING | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-004A | TODO | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
|
||||
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
@@ -14,3 +17,9 @@
|
||||
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
|
||||
|
||||
> 2025-11-02: AIAI-31-002 – SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.
|
||||
|
||||
> 2025-11-02: AIAI-31-003 moved to DOING – starting deterministic tooling surface (version comparators & dependency analysis). Added semantic-version + EVR comparators and published toolset interface; awaiting downstream wiring.
|
||||
|
||||
> 2025-11-02: AIAI-31-004 started orchestration pipeline work – begin designing summary/conflict/remediation workflow (deterministic sequence + cache keys).
|
||||
|
||||
> 2025-11-02: AIAI-31-004 orchestration prerequisites documented in docs/modules/advisory-ai/orchestration-pipeline.md (task breakdown 004A/004B/004C).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Summarises dependency graph characteristics used by deterministic tooling.
|
||||
/// </summary>
|
||||
public sealed class DependencyAnalysisResult
|
||||
{
|
||||
private DependencyAnalysisResult(
|
||||
string artifactId,
|
||||
ImmutableArray<DependencyNodeSummary> nodes,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
ArtifactId = artifactId;
|
||||
Nodes = nodes;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public string ArtifactId { get; }
|
||||
|
||||
public ImmutableArray<DependencyNodeSummary> Nodes { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public static DependencyAnalysisResult Create(
|
||||
string artifactId,
|
||||
IEnumerable<DependencyNodeSummary> nodes,
|
||||
IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
return new DependencyAnalysisResult(
|
||||
artifactId.Trim(),
|
||||
nodes.ToImmutableArray(),
|
||||
metadata.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public static DependencyAnalysisResult Empty(string artifactId)
|
||||
=> new DependencyAnalysisResult(
|
||||
artifactId?.Trim() ?? string.Empty,
|
||||
ImmutableArray<DependencyNodeSummary>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
public sealed class DependencyNodeSummary
|
||||
{
|
||||
public DependencyNodeSummary(
|
||||
string identifier,
|
||||
IReadOnlyList<string> versions,
|
||||
int runtimeOccurrences,
|
||||
int developmentOccurrences)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
|
||||
|
||||
Identifier = identifier.Trim();
|
||||
Versions = versions?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
RuntimeOccurrences = Math.Max(runtimeOccurrences, 0);
|
||||
DevelopmentOccurrences = Math.Max(developmentOccurrences, 0);
|
||||
}
|
||||
|
||||
public string Identifier { get; }
|
||||
|
||||
public ImmutableArray<string> Versions { get; }
|
||||
|
||||
public int RuntimeOccurrences { get; }
|
||||
|
||||
public int DevelopmentOccurrences { get; }
|
||||
}
|
||||
|
||||
internal sealed class NodeAccumulator
|
||||
{
|
||||
public string Identifier { get; set; } = string.Empty;
|
||||
|
||||
public HashSet<string> Versions { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int RuntimeOccurrences { get; set; }
|
||||
|
||||
public int DevelopmentOccurrences { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Default deterministic toolset covering semantic versioning and RPM-style EVR comparisons.
|
||||
/// </summary>
|
||||
internal sealed class DeterministicToolset : IDeterministicToolset
|
||||
{
|
||||
private const string SchemeSemver = "semver";
|
||||
private const string SchemeEvr = "evr";
|
||||
|
||||
public bool TryCompare(string scheme, string left, string right, out int comparison)
|
||||
{
|
||||
comparison = 0;
|
||||
scheme = NormalizeScheme(scheme);
|
||||
|
||||
return scheme switch
|
||||
{
|
||||
SchemeSemver => TryCompareSemver(left, right, out comparison),
|
||||
SchemeEvr => TryCompareEvr(left, right, out comparison),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public bool SatisfiesRange(string scheme, string version, string rangeExpression)
|
||||
{
|
||||
scheme = NormalizeScheme(scheme);
|
||||
return scheme switch
|
||||
{
|
||||
SchemeSemver => SemanticVersionRange.Satisfies(version, rangeExpression),
|
||||
SchemeEvr => EvrRangeSatisfies(version, rangeExpression),
|
||||
_ => throw new NotSupportedException($"Scheme '{scheme}' not supported."),
|
||||
};
|
||||
}
|
||||
|
||||
public DependencyAnalysisResult AnalyzeDependencies(SbomContextResult context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (context.DependencyPaths.Count == 0)
|
||||
{
|
||||
return DependencyAnalysisResult.Empty(context.ArtifactId);
|
||||
}
|
||||
|
||||
var nodes = new Dictionary<string, NodeAccumulator>(StringComparer.Ordinal);
|
||||
var totalPaths = 0;
|
||||
var runtimePaths = 0;
|
||||
|
||||
foreach (var path in context.DependencyPaths)
|
||||
{
|
||||
totalPaths++;
|
||||
if (path.IsRuntime)
|
||||
{
|
||||
runtimePaths++;
|
||||
}
|
||||
|
||||
foreach (var node in path.Nodes)
|
||||
{
|
||||
var key = node.Identifier;
|
||||
if (!nodes.TryGetValue(key, out var accumulator))
|
||||
{
|
||||
accumulator = new NodeAccumulator
|
||||
{
|
||||
Identifier = node.Identifier,
|
||||
Versions = new HashSet<string>(StringComparer.Ordinal),
|
||||
};
|
||||
nodes[key] = accumulator;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.Version))
|
||||
{
|
||||
accumulator.Versions.Add(node.Version!);
|
||||
}
|
||||
|
||||
if (path.IsRuntime)
|
||||
{
|
||||
accumulator.RuntimeOccurrences++;
|
||||
}
|
||||
else
|
||||
{
|
||||
accumulator.DevelopmentOccurrences++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var summaries = nodes.Values
|
||||
.Select(acc => new DependencyNodeSummary(
|
||||
acc.Identifier,
|
||||
acc.Versions.OrderBy(v => v, StringComparer.Ordinal).ToArray(),
|
||||
acc.RuntimeOccurrences,
|
||||
acc.DevelopmentOccurrences))
|
||||
.OrderBy(summary => summary.Identifier, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["artifact_id"] = context.ArtifactId,
|
||||
["path_count"] = totalPaths.ToString(CultureInfo.InvariantCulture),
|
||||
["runtime_path_count"] = runtimePaths.ToString(CultureInfo.InvariantCulture),
|
||||
["development_path_count"] = (totalPaths - runtimePaths).ToString(CultureInfo.InvariantCulture),
|
||||
["unique_nodes"] = summaries.Length.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
return new DependencyAnalysisResult(context.ArtifactId, summaries, metadata);
|
||||
}
|
||||
|
||||
private static string NormalizeScheme(string scheme)
|
||||
=> string.IsNullOrWhiteSpace(scheme) ? SchemeSemver : scheme.Trim().ToLowerInvariant();
|
||||
|
||||
private static bool TryCompareSemver(string left, string right, out int comparison)
|
||||
{
|
||||
comparison = 0;
|
||||
if (!SemanticVersion.TryParse(left, out var leftVersion) ||
|
||||
!SemanticVersion.TryParse(right, out var rightVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
comparison = leftVersion.CompareTo(rightVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCompareEvr(string left, string right, out int comparison)
|
||||
{
|
||||
comparison = 0;
|
||||
if (!EvrVersion.TryParse(left, out var leftVersion) ||
|
||||
!EvrVersion.TryParse(right, out var rightVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
comparison = leftVersion.CompareTo(rightVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EvrRangeSatisfies(string version, string rangeExpression)
|
||||
{
|
||||
if (!EvrVersion.TryParse(version, out var parsed))
|
||||
{
|
||||
throw new FormatException($"Invalid EVR version '{version}'.");
|
||||
}
|
||||
|
||||
var clauses = rangeExpression
|
||||
.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var clause in clauses)
|
||||
{
|
||||
if (!EvaluateEvrClause(parsed, clause))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EvaluateEvrClause(EvrVersion version, string clause)
|
||||
{
|
||||
if (!TryParseComparator(clause, out var comparator, out var targetRaw))
|
||||
{
|
||||
targetRaw = clause;
|
||||
comparator = Comparator.Equals;
|
||||
}
|
||||
|
||||
if (!EvrVersion.TryParse(targetRaw, out var target))
|
||||
{
|
||||
throw new FormatException($"Invalid EVR version '{targetRaw}' in clause '{clause}'.");
|
||||
}
|
||||
|
||||
var compare = version.CompareTo(target);
|
||||
return comparator switch
|
||||
{
|
||||
Comparator.Equals => compare == 0,
|
||||
Comparator.NotEquals => compare != 0,
|
||||
Comparator.GreaterThan => compare > 0,
|
||||
Comparator.GreaterThanOrEqual => compare >= 0,
|
||||
Comparator.LessThan => compare < 0,
|
||||
Comparator.LessThanOrEqual => compare <= 0,
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseComparator(string clause, out Comparator comparator, out string version)
|
||||
{
|
||||
comparator = Comparator.Equals;
|
||||
version = clause;
|
||||
if (string.IsNullOrWhiteSpace(clause))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clause.StartsWith(">=", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.GreaterThanOrEqual;
|
||||
version = clause[2..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith("<=", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.LessThanOrEqual;
|
||||
version = clause[2..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith("!=", StringComparison.Ordinal) || clause.StartsWith("<>", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.NotEquals;
|
||||
version = clause[2..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith(">", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.GreaterThan;
|
||||
version = clause[1..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith("<", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.LessThan;
|
||||
version = clause[1..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith("=", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.Equals;
|
||||
version = clause[1..];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clause.StartsWith("==", StringComparison.Ordinal))
|
||||
{
|
||||
comparator = Comparator.Equals;
|
||||
version = clause[2..];
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private enum Comparator
|
||||
{
|
||||
Equals,
|
||||
NotEquals,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
}
|
||||
|
||||
private readonly struct EvrVersion : IComparable<EvrVersion>
|
||||
{
|
||||
public EvrVersion(int epoch, IReadOnlyList<string> versionSegments, IReadOnlyList<string> releaseSegments)
|
||||
{
|
||||
Epoch = epoch;
|
||||
VersionSegments = versionSegments;
|
||||
ReleaseSegments = releaseSegments;
|
||||
}
|
||||
|
||||
public int Epoch { get; }
|
||||
|
||||
public IReadOnlyList<string> VersionSegments { get; }
|
||||
|
||||
public IReadOnlyList<string> ReleaseSegments { get; }
|
||||
|
||||
public static bool TryParse(string value, out EvrVersion version)
|
||||
{
|
||||
version = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var epochSplit = trimmed.Split(':', 2);
|
||||
int epoch = 0;
|
||||
string remainder;
|
||||
|
||||
if (epochSplit.Length == 2)
|
||||
{
|
||||
if (!int.TryParse(epochSplit[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remainder = epochSplit[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
remainder = trimmed;
|
||||
}
|
||||
|
||||
var releaseSplit = remainder.Split('-', 2);
|
||||
var versionPart = releaseSplit[0];
|
||||
var releasePart = releaseSplit.Length == 2 ? releaseSplit[1] : string.Empty;
|
||||
|
||||
var versionSegments = SplitSegments(versionPart);
|
||||
var releaseSegments = SplitSegments(releasePart);
|
||||
|
||||
version = new EvrVersion(epoch, versionSegments, releaseSegments);
|
||||
return true;
|
||||
}
|
||||
|
||||
public int CompareTo(EvrVersion other)
|
||||
{
|
||||
var epochCompare = Epoch.CompareTo(other.Epoch);
|
||||
if (epochCompare != 0)
|
||||
{
|
||||
return epochCompare;
|
||||
}
|
||||
|
||||
var versionCompare = CompareSegments(VersionSegments, other.VersionSegments);
|
||||
if (versionCompare != 0)
|
||||
{
|
||||
return versionCompare;
|
||||
}
|
||||
|
||||
return CompareSegments(ReleaseSegments, other.ReleaseSegments);
|
||||
}
|
||||
|
||||
private static List<string> SplitSegments(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return new List<string>(0);
|
||||
}
|
||||
|
||||
var segments = value.Split(new[] { '.', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
return segments.ToList();
|
||||
}
|
||||
|
||||
private static int CompareSegments(IReadOnlyList<string> left, IReadOnlyList<string> right)
|
||||
{
|
||||
var length = Math.Max(left.Count, right.Count);
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var leftSegment = i < left.Count ? left[i] : string.Empty;
|
||||
var rightSegment = i < right.Count ? right[i] : string.Empty;
|
||||
|
||||
var leftNumeric = int.TryParse(leftSegment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leftValue);
|
||||
var rightNumeric = int.TryParse(rightSegment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rightValue);
|
||||
|
||||
if (leftNumeric && rightNumeric)
|
||||
{
|
||||
var compare = leftValue.CompareTo(rightValue);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftNumeric)
|
||||
{
|
||||
return 1; // numeric segments sort after alpha if other not numeric
|
||||
}
|
||||
|
||||
if (rightNumeric)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var cmp = string.CompareOrdinal(leftSegment, rightSegment);
|
||||
if (cmp != 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic version comparison and range evaluation helpers used across Advisory AI tooling.
|
||||
/// </summary>
|
||||
public interface IDeterministicToolset
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to compare two versions using the specified scheme.
|
||||
/// </summary>
|
||||
bool TryCompare(string scheme, string left, string right, out int comparison);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a version satisfies the given range expression for the specified scheme.
|
||||
/// </summary>
|
||||
bool SatisfiesRange(string scheme, string version, string rangeExpression);
|
||||
|
||||
/// <summary>
|
||||
/// Analyses dependency paths to produce deterministic summaries for policy/tooling decisions.
|
||||
/// </summary>
|
||||
DependencyAnalysisResult AnalyzeDependencies(SbomContextResult context);
|
||||
}
|
||||
256
src/AdvisoryAI/StellaOps.AdvisoryAI/Tools/SemanticVersion.cs
Normal file
256
src/AdvisoryAI/StellaOps.AdvisoryAI/Tools/SemanticVersion.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic semantic version parser that supports major.minor.patch with optional pre-release/build metadata.
|
||||
/// No external dependencies to remain offline-friendly.
|
||||
/// </summary>
|
||||
public readonly struct SemanticVersion : IComparable<SemanticVersion>
|
||||
{
|
||||
private SemanticVersion(
|
||||
int major,
|
||||
int minor,
|
||||
int patch,
|
||||
IReadOnlyList<string> preRelease,
|
||||
string? build)
|
||||
{
|
||||
Major = major;
|
||||
Minor = minor;
|
||||
Patch = patch;
|
||||
PreRelease = preRelease;
|
||||
BuildMetadata = build;
|
||||
}
|
||||
|
||||
public int Major { get; }
|
||||
|
||||
public int Minor { get; }
|
||||
|
||||
public int Patch { get; }
|
||||
|
||||
public IReadOnlyList<string> PreRelease { get; }
|
||||
|
||||
public string? BuildMetadata { get; }
|
||||
|
||||
public static bool TryParse(string value, out SemanticVersion version)
|
||||
{
|
||||
version = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var span = value.Trim();
|
||||
var buildSplit = span.Split('+', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
string? build = null;
|
||||
if (buildSplit.Length == 2)
|
||||
{
|
||||
span = buildSplit[0];
|
||||
build = buildSplit[1];
|
||||
}
|
||||
|
||||
var preReleaseSplit = span.Split('-', 2, StringSplitOptions.None);
|
||||
string? preReleaseSegment = null;
|
||||
if (preReleaseSplit.Length == 2)
|
||||
{
|
||||
span = preReleaseSplit[0];
|
||||
preReleaseSegment = preReleaseSplit[1];
|
||||
if (string.IsNullOrEmpty(preReleaseSegment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var parts = span.Split('.', StringSplitOptions.None);
|
||||
if (parts.Length < 1 || parts.Length > 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseNumericPart(parts[0], out var major))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minor = 0;
|
||||
if (parts.Length > 1 && !TryParseNumericPart(parts[1], out minor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = 0;
|
||||
if (parts.Length > 2 && !TryParseNumericPart(parts[2], out patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var preRelease = Array.Empty<string>();
|
||||
if (!string.IsNullOrEmpty(preReleaseSegment))
|
||||
{
|
||||
var segments = preReleaseSegment.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var identifier in segments)
|
||||
{
|
||||
if (!IsValidIdentifier(identifier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
preRelease = segments;
|
||||
}
|
||||
|
||||
version = new SemanticVersion(major, minor, patch, preRelease, build);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static SemanticVersion Parse(string value)
|
||||
=> TryParse(value, out var version)
|
||||
? version
|
||||
: throw new FormatException($"Invalid semantic version '{value}'.");
|
||||
|
||||
public int CompareTo(SemanticVersion other)
|
||||
{
|
||||
var majorCompare = Major.CompareTo(other.Major);
|
||||
if (majorCompare != 0)
|
||||
{
|
||||
return majorCompare;
|
||||
}
|
||||
|
||||
var minorCompare = Minor.CompareTo(other.Minor);
|
||||
if (minorCompare != 0)
|
||||
{
|
||||
return minorCompare;
|
||||
}
|
||||
|
||||
var patchCompare = Patch.CompareTo(other.Patch);
|
||||
if (patchCompare != 0)
|
||||
{
|
||||
return patchCompare;
|
||||
}
|
||||
|
||||
return ComparePreRelease(PreRelease, other.PreRelease);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var core = $"{Major}.{Minor}.{Patch}";
|
||||
if (PreRelease.Count > 0)
|
||||
{
|
||||
core += "-" + string.Join('.', PreRelease);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(BuildMetadata))
|
||||
{
|
||||
core += "+" + BuildMetadata;
|
||||
}
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
private static bool TryParseNumericPart(string value, out int result)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
result = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.Length > 1 && value[0] == '0')
|
||||
{
|
||||
result = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
private static bool IsValidIdentifier(string identifier)
|
||||
{
|
||||
if (identifier.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in identifier)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '-')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (identifier.Length > 1 && identifier[0] == '0' && char.IsDigit(identifier[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ComparePreRelease(IReadOnlyList<string> left, IReadOnlyList<string> right)
|
||||
{
|
||||
var leftEmpty = left.Count == 0;
|
||||
var rightEmpty = right.Count == 0;
|
||||
|
||||
if (leftEmpty && rightEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftEmpty)
|
||||
{
|
||||
return 1; // release > pre-release
|
||||
}
|
||||
|
||||
if (rightEmpty)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var length = Math.Min(left.Count, right.Count);
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var leftId = left[i];
|
||||
var rightId = right[i];
|
||||
|
||||
var leftNumeric = int.TryParse(leftId, NumberStyles.None, CultureInfo.InvariantCulture, out var leftValue);
|
||||
var rightNumeric = int.TryParse(rightId, NumberStyles.None, CultureInfo.InvariantCulture, out var rightValue);
|
||||
|
||||
if (leftNumeric && rightNumeric)
|
||||
{
|
||||
var compare = leftValue.CompareTo(rightValue);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
else if (leftNumeric)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
else if (rightNumeric)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var compare = string.CompareOrdinal(leftId, rightId);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return left.Count.CompareTo(right.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates simple semantic version ranges used by Advisory AI deterministic tooling.
|
||||
/// Supports comparators (>, >=, <, <=, =, ==, !=) combined with commas (logical AND).
|
||||
/// </summary>
|
||||
public static class SemanticVersionRange
|
||||
{
|
||||
private static readonly char[] ComparatorChars = ['>', '<', '!', '='];
|
||||
|
||||
public static bool Satisfies(string version, string rangeExpression)
|
||||
{
|
||||
if (!SemanticVersion.TryParse(version, out var parsedVersion))
|
||||
{
|
||||
throw new FormatException($"Invalid version '{version}'.");
|
||||
}
|
||||
|
||||
var clauses = ParseClauses(rangeExpression);
|
||||
foreach (var clause in clauses)
|
||||
{
|
||||
if (!EvaluateClause(parsedVersion, clause))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RangeClause> ParseClauses(string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return Array.Empty<RangeClause>();
|
||||
}
|
||||
|
||||
var tokens = expression.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var clauses = new List<RangeClause>(tokens.Length);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
clauses.Add(ParseClause(token));
|
||||
}
|
||||
|
||||
return clauses;
|
||||
}
|
||||
|
||||
private static RangeClause ParseClause(string token)
|
||||
{
|
||||
var opLength = 0;
|
||||
for (; opLength < token.Length; opLength++)
|
||||
{
|
||||
var ch = token[opLength];
|
||||
if (Array.IndexOf(ComparatorChars, ch) < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (opLength == 0)
|
||||
{
|
||||
// implicit equality
|
||||
if (!SemanticVersion.TryParse(token, out var implicitVersion))
|
||||
{
|
||||
throw new FormatException($"Invalid range clause '{token}'.");
|
||||
}
|
||||
|
||||
return new RangeClause(Comparator.Equals, implicitVersion);
|
||||
}
|
||||
|
||||
var opToken = token[..opLength];
|
||||
var comparator = opToken switch
|
||||
{
|
||||
">" => Comparator.GreaterThan,
|
||||
">=" => Comparator.GreaterThanOrEqual,
|
||||
"<" => Comparator.LessThan,
|
||||
"<=" => Comparator.LessThanOrEqual,
|
||||
"=" or "==" => Comparator.Equals,
|
||||
"!=" or "<>" => Comparator.NotEquals,
|
||||
_ => throw new FormatException($"Unsupported comparator '{opToken}'."),
|
||||
};
|
||||
|
||||
var versionToken = token[opLength..];
|
||||
if (!SemanticVersion.TryParse(versionToken, out var version))
|
||||
{
|
||||
throw new FormatException($"Invalid version '{versionToken}' in clause '{token}'.");
|
||||
}
|
||||
|
||||
return new RangeClause(comparator, version);
|
||||
}
|
||||
|
||||
private static bool EvaluateClause(SemanticVersion version, RangeClause clause)
|
||||
{
|
||||
var compare = version.CompareTo(clause.Version);
|
||||
return clause.Comparator switch
|
||||
{
|
||||
Comparator.Equals => compare == 0,
|
||||
Comparator.NotEquals => compare != 0,
|
||||
Comparator.GreaterThan => compare > 0,
|
||||
Comparator.GreaterThanOrEqual => compare >= 0,
|
||||
Comparator.LessThan => compare < 0,
|
||||
Comparator.LessThanOrEqual => compare <= 0,
|
||||
_ => throw new InvalidOperationException($"Unsupported comparator {clause.Comparator}."),
|
||||
};
|
||||
}
|
||||
|
||||
private readonly record struct RangeClause(Comparator Comparator, SemanticVersion Version);
|
||||
|
||||
private enum Comparator
|
||||
{
|
||||
Equals,
|
||||
NotEquals,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user