partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -7,8 +7,11 @@ using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -28,10 +31,16 @@ internal static class AdviseChatCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var queryArgument = new Argument<string>("query")
var queryArgument = new Argument<string?>("query")
{
Description = "The question or query to ask the advisory assistant."
};
queryArgument.Arity = ArgumentArity.ZeroOrOne;
var fileOption = new Option<string?>("--file")
{
Description = "Read batch queries from newline-delimited JSON (.jsonl)."
};
var imageOption = new Option<string?>("--image", new[] { "-i" })
{
@@ -95,6 +104,7 @@ internal static class AdviseChatCommandGroup
conversationOption,
noActionOption,
evidenceOption,
fileOption,
formatOption,
outputOption,
tenantOption,
@@ -104,13 +114,14 @@ internal static class AdviseChatCommandGroup
ask.SetAction(async (parseResult, ct) =>
{
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
var query = parseResult.GetValue(queryArgument);
var image = parseResult.GetValue(imageOption);
var digest = parseResult.GetValue(digestOption);
var env = parseResult.GetValue(envOption);
var conversationId = parseResult.GetValue(conversationOption);
var noAction = parseResult.GetValue(noActionOption);
var evidence = parseResult.GetValue(evidenceOption);
var filePath = parseResult.GetValue(fileOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var tenant = parseResult.GetValue(tenantOption);
@@ -127,6 +138,7 @@ internal static class AdviseChatCommandGroup
conversationId,
noAction,
evidence,
filePath,
format,
outputPath,
tenant,
@@ -138,6 +150,85 @@ internal static class AdviseChatCommandGroup
return ask;
}
/// <summary>
/// Build the 'advise export' command for exporting conversation history.
/// </summary>
public static Command BuildExportCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var conversationIdOption = new Option<string?>("--conversation-id", new[] { "-c" })
{
Description = "Export a single conversation id."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context for listing conversations."
};
var userOption = new Option<string?>("--user")
{
Description = "User context for listing conversations."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of conversations to export (default: 100)."
};
limitOption.SetDefaultValue(100);
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: json, table, markdown (default: json)."
};
formatOption.SetDefaultValue("json");
formatOption.FromAmong("json", "table", "markdown");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write export output to file instead of stdout."
};
var export = new Command("export", "Export advisory conversation history.")
{
conversationIdOption,
tenantOption,
userOption,
limitOption,
formatOption,
outputOption,
verboseOption
};
export.SetAction(async (parseResult, ct) =>
{
var conversationId = parseResult.GetValue(conversationIdOption);
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var limit = parseResult.GetValue(limitOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleExportAsync(
services,
options,
conversationId,
tenant,
user,
limit,
format,
outputPath,
verbose,
cancellationToken).ConfigureAwait(false);
});
return export;
}
/// <summary>
/// Build the 'advise doctor' command for chat diagnostics.
/// </summary>
@@ -467,16 +558,25 @@ internal static class AdviseChatCommandGroup
};
}
private sealed record BatchQueryEntry(int LineNumber, string Query);
private sealed record BatchQueryResult(
int LineNumber,
string Query,
ChatQueryResponse? Response,
string? Error);
private static async Task HandleAskAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string query,
string? query,
string? image,
string? digest,
string? environment,
string? conversationId,
bool noAction,
bool includeEvidence,
string? filePath,
ChatOutputFormat format,
string? outputPath,
string? tenant,
@@ -502,9 +602,38 @@ internal static class AdviseChatCommandGroup
return;
}
if (!string.IsNullOrWhiteSpace(filePath))
{
try
{
await HandleAskBatchAsync(
services,
options,
filePath,
image,
digest,
environment,
conversationId,
noAction,
includeEvidence,
format,
outputPath,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
Console.Error.WriteLine($"Batch query failed: {ex.Message}");
}
return;
}
if (string.IsNullOrWhiteSpace(query))
{
Console.Error.WriteLine("Error: Query cannot be empty.");
Console.Error.WriteLine("Error: Query cannot be empty. Provide 'query' or '--file'.");
return;
}
@@ -565,6 +694,235 @@ internal static class AdviseChatCommandGroup
}
}
private static async Task HandleAskBatchAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string filePath,
string? image,
string? digest,
string? environment,
string? conversationId,
bool noAction,
bool includeEvidence,
ChatOutputFormat format,
string? outputPath,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
var requests = await ReadBatchRequestsAsync(filePath, cancellationToken).ConfigureAwait(false);
if (requests.Count == 0)
{
Console.Error.WriteLine("Error: Batch file contained no queries.");
return;
}
if (verbose)
{
Console.Error.WriteLine($"Processing {requests.Count} query entries from '{filePath}'.");
}
var results = new List<BatchQueryResult>(requests.Count);
foreach (var entry in requests)
{
var request = new ChatQueryRequest
{
Query = entry.Query,
ImageReference = image,
ArtifactDigest = digest,
Environment = environment,
ConversationId = conversationId,
NoAction = noAction,
IncludeEvidence = includeEvidence
};
try
{
var response = await client.QueryAsync(request, tenant, user, cancellationToken).ConfigureAwait(false);
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, response, null));
}
catch (ChatException ex)
{
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, null, ex.Message));
}
}
await using var writer = GetOutputWriter(outputPath);
if (format == ChatOutputFormat.Json)
{
var payload = new
{
file = Path.GetFileName(filePath),
count = results.Count,
results = results.Select(static item => new
{
item.LineNumber,
item.Query,
Error = item.Error,
Response = item.Response
})
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
return;
}
foreach (var item in results)
{
await writer.WriteLineAsync($"# Batch line {item.LineNumber}".AsMemory(), cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(item.Error))
{
await writer.WriteLineAsync($"Error: {item.Error}".AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
continue;
}
await writer.WriteLineAsync($"Query: {item.Query}".AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
await ChatRenderer.RenderQueryResponseAsync(item.Response!, format, writer, cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
}
}
private static async Task<List<BatchQueryEntry>> ReadBatchRequestsAsync(
string filePath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new InvalidOperationException("Batch file path must be provided.");
}
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Batch query file was not found.", filePath);
}
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
var results = new List<BatchQueryEntry>(lines.Length);
for (var i = 0; i < lines.Length; i++)
{
var lineNumber = i + 1;
var line = lines[i]?.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.String)
{
var query = root.GetString();
if (string.IsNullOrWhiteSpace(query))
{
throw new InvalidOperationException($"Batch line {lineNumber} contains empty query.");
}
results.Add(new BatchQueryEntry(lineNumber, query));
continue;
}
if (root.ValueKind != JsonValueKind.Object ||
!TryGetStringProperty(root, "query", out var jsonQuery) ||
string.IsNullOrWhiteSpace(jsonQuery))
{
throw new InvalidOperationException($"Batch line {lineNumber} must be a JSON string or object with non-empty 'query'.");
}
results.Add(new BatchQueryEntry(lineNumber, jsonQuery!));
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Batch line {lineNumber} is not valid JSON: {ex.Message}", ex);
}
}
return results;
}
private static bool TryGetStringProperty(JsonElement element, string name, out string? value)
{
value = null;
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
value = property.Value.GetString();
return true;
}
}
return false;
}
private static async Task HandleExportAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? conversationId,
string? tenant,
string? user,
int? limit,
ChatOutputFormat format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
var conversations = new List<ChatConversationResponse>();
try
{
if (!string.IsNullOrWhiteSpace(conversationId))
{
if (verbose)
{
Console.Error.WriteLine($"Exporting conversation '{conversationId}'.");
}
var single = await client.GetConversationAsync(conversationId, tenant, user, cancellationToken).ConfigureAwait(false);
conversations.Add(single);
}
else
{
if (verbose)
{
Console.Error.WriteLine($"Listing conversations (limit: {limit ?? 100}).");
}
var listed = await client.ListConversationsAsync(tenant, user, limit, cancellationToken).ConfigureAwait(false);
foreach (var summary in listed.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
conversations.Add(await client.GetConversationAsync(summary.ConversationId, tenant, user, cancellationToken).ConfigureAwait(false));
}
}
var export = new ChatConversationExport
{
GeneratedAt = DateTimeOffset.UtcNow,
TenantId = tenant,
UserId = user,
ConversationCount = conversations.Count,
Conversations = conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal)
.ToList()
};
await using var writer = GetOutputWriter(outputPath);
await ChatRenderer.RenderConversationExportAsync(export, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Export failed: {ex.Message}");
}
}
private static async Task HandleDoctorAsync(
IServiceProvider services,
StellaOpsCliOptions options,

View File

@@ -100,6 +100,33 @@ internal static class ChatRenderer
}
}
/// <summary>
/// Render a chat conversation export.
/// </summary>
public static async Task RenderConversationExportAsync(
ChatConversationExport export,
ChatOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(export);
ArgumentNullException.ThrowIfNull(writer);
switch (format)
{
case ChatOutputFormat.Markdown:
await RenderConversationExportMarkdownAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Table:
await RenderConversationExportTableAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Json:
default:
await RenderConversationExportJsonAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
}
}
private static async Task RenderQueryJsonAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, JsonOptions);
@@ -429,4 +456,78 @@ internal static class ChatRenderer
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportJsonAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(export, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportTableAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("=== Advisory Conversation Export ===");
sb.AppendLine();
sb.AppendLine($"Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Tenant: {export.TenantId ?? "(not set)"}");
sb.AppendLine($"User: {export.UserId ?? "(not set)"}");
sb.AppendLine($"Conversations: {export.ConversationCount}");
sb.AppendLine();
foreach (var conversation in export.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
sb.AppendLine($"--- Conversation {conversation.ConversationId} ---");
sb.AppendLine($"Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Turns: {conversation.Turns.Count}");
foreach (var turn in conversation.Turns
.OrderBy(static item => item.Timestamp)
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
{
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
sb.AppendLine($" [{turn.Timestamp:yyyy-MM-dd HH:mm:ss}] {role}: {turn.Content}");
}
sb.AppendLine();
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportMarkdownAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine("# Advisory Conversation Export");
sb.AppendLine();
sb.AppendLine($"- Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Tenant: {export.TenantId ?? "(not set)"}");
sb.AppendLine($"- User: {export.UserId ?? "(not set)"}");
sb.AppendLine($"- Conversations: {export.ConversationCount}");
sb.AppendLine();
foreach (var conversation in export.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
sb.AppendLine($"## {conversation.ConversationId}");
sb.AppendLine();
sb.AppendLine($"- Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Turns: {conversation.Turns.Count}");
sb.AppendLine();
foreach (var turn in conversation.Turns
.OrderBy(static item => item.Timestamp)
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
{
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
sb.AppendLine($"- **{role}** ({turn.Timestamp:yyyy-MM-dd HH:mm:ss} UTC): {turn.Content}");
}
sb.AppendLine();
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -4490,6 +4490,7 @@ flowchart TB
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildDoctorCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildSettingsCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildExportCommand(services, options, verboseOption, cancellationToken));
return advise;
}

View File

@@ -1,12 +1,14 @@
// -----------------------------------------------------------------------------
// CompareCommandBuilder.cs
// Sprint: SPRINT_4200_0002_0004_cli_compare
// Updated: SPRINT_20260208_029_Cli_baseline_selection_logic
// Description: CLI commands for comparing scan snapshots.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services;
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -16,6 +18,7 @@ namespace StellaOps.Cli.Commands.Compare;
/// <summary>
/// Builds CLI commands for comparing scan snapshots.
/// Per SPRINT_4200_0002_0004.
/// Updated for baseline strategies per SPRINT_20260208_029.
/// </summary>
internal static class CompareCommandBuilder
{
@@ -34,10 +37,11 @@ internal static class CompareCommandBuilder
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var baseDigestOption = new Option<string>("--base", new[] { "-b" })
// Base can now be optional when using baseline strategies
var baseDigestOption = new Option<string?>("--base", new[] { "-b" })
{
Description = "Base snapshot digest (the 'before' state)",
Required = true
Description = "Base snapshot digest (the 'before' state). Optional when using --baseline-strategy.",
Required = false
};
var targetDigestOption = new Option<string>("--target", new[] { "-t" })
@@ -46,6 +50,22 @@ internal static class CompareCommandBuilder
Required = true
};
// SPRINT_20260208_029: Baseline strategy options
var baselineStrategyOption = new Option<string?>("--baseline-strategy")
{
Description = "Strategy for selecting baseline: 'explicit' (default, requires --base), 'last-green' (most recent passing), or 'previous-release' (previous release tag)"
};
var artifactOption = new Option<string?>("--artifact", new[] { "-a" })
{
Description = "Artifact identifier (PURL, OCI reference, or path) for baseline resolution. Required when using non-explicit strategies."
};
var currentVersionOption = new Option<string?>("--current-version")
{
Description = "Current version/tag for context (helps with previous-release strategy)"
};
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output format (table, json, sarif)"
@@ -71,33 +91,70 @@ internal static class CompareCommandBuilder
Description = "Scanner WebService URL override"
};
var verificationReportOption = new Option<string?>("--verification-report")
{
Description = "Path to JSON output from 'stella bundle verify --output json' for compare verification overlay"
};
var reverifyBundleOption = new Option<string?>("--reverify-bundle")
{
Description = "Path to local evidence bundle directory to recompute hash/signature status inline with compare output"
};
var determinismManifestOption = new Option<string?>("--determinism-manifest")
{
Description = "Path to determinism manifest JSON used to attach determinism score context to compare output"
};
// compare diff - Full comparison
var diffCommand = new Command("diff", "Compare two scan snapshots and show detailed diff.");
diffCommand.Add(baseDigestOption);
diffCommand.Add(targetDigestOption);
diffCommand.Add(baselineStrategyOption);
diffCommand.Add(artifactOption);
diffCommand.Add(currentVersionOption);
diffCommand.Add(outputOption);
diffCommand.Add(outputFileOption);
diffCommand.Add(includeUnchangedOption);
diffCommand.Add(severityFilterOption);
diffCommand.Add(backendUrlOption);
diffCommand.Add(verificationReportOption);
diffCommand.Add(reverifyBundleOption);
diffCommand.Add(determinismManifestOption);
diffCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var outputFile = parseResult.GetValue(outputFileOption);
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
var severity = parseResult.GetValue(severityFilterOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
var verificationReportPath = parseResult.GetValue(verificationReportOption);
var reverifyBundlePath = parseResult.GetValue(reverifyBundleOption);
var determinismManifestPath = parseResult.GetValue(determinismManifestOption);
var verbose = parseResult.GetValue(verboseOption);
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var request = new CompareRequest
{
BaseDigest = baseDigest,
BaseDigest = resolvedBase,
TargetDigest = targetDigest,
IncludeUnchanged = includeUnchanged,
SeverityFilter = severity,
@@ -105,6 +162,16 @@ internal static class CompareCommandBuilder
};
var result = await client.CompareAsync(request, cancellationToken);
var verification = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath,
reverifyBundlePath,
determinismManifestPath,
cancellationToken);
if (verification is not null)
{
result = result with { Verification = verification };
}
await WriteOutputAsync(result, output, outputFile, renderer, verbose);
});
@@ -113,12 +180,18 @@ internal static class CompareCommandBuilder
var summaryCommand = new Command("summary", "Show quick summary of changes between snapshots.");
summaryCommand.Add(baseDigestOption);
summaryCommand.Add(targetDigestOption);
summaryCommand.Add(baselineStrategyOption);
summaryCommand.Add(artifactOption);
summaryCommand.Add(currentVersionOption);
summaryCommand.Add(outputOption);
summaryCommand.Add(backendUrlOption);
summaryCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var backendUrl = parseResult.GetValue(backendUrlOption);
var verbose = parseResult.GetValue(verboseOption);
@@ -126,8 +199,18 @@ internal static class CompareCommandBuilder
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
WriteSummary(result, output, renderer, verbose);
});
@@ -136,18 +219,35 @@ internal static class CompareCommandBuilder
var canShipCommand = new Command("can-ship", "Check if target snapshot can ship relative to base.");
canShipCommand.Add(baseDigestOption);
canShipCommand.Add(targetDigestOption);
canShipCommand.Add(baselineStrategyOption);
canShipCommand.Add(artifactOption);
canShipCommand.Add(currentVersionOption);
canShipCommand.Add(backendUrlOption);
canShipCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
var verbose = parseResult.GetValue(verboseOption);
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
Environment.ExitCode = 1;
return;
}
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
WriteCanShipResult(result, verbose);
@@ -161,13 +261,19 @@ internal static class CompareCommandBuilder
var vulnsCommand = new Command("vulns", "List vulnerability changes between snapshots.");
vulnsCommand.Add(baseDigestOption);
vulnsCommand.Add(targetDigestOption);
vulnsCommand.Add(baselineStrategyOption);
vulnsCommand.Add(artifactOption);
vulnsCommand.Add(currentVersionOption);
vulnsCommand.Add(outputOption);
vulnsCommand.Add(severityFilterOption);
vulnsCommand.Add(backendUrlOption);
vulnsCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var severity = parseResult.GetValue(severityFilterOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
@@ -176,10 +282,20 @@ internal static class CompareCommandBuilder
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var request = new CompareRequest
{
BaseDigest = baseDigest,
BaseDigest = resolvedBase,
TargetDigest = targetDigest,
SeverityFilter = severity,
BackendUrl = backendUrl
@@ -263,6 +379,50 @@ internal static class CompareCommandBuilder
{
Console.WriteLine($"Policy Verdict: {result.TargetVerdict} (unchanged)");
}
if (result.Verification is null)
{
return;
}
Console.WriteLine();
Console.WriteLine($"Verification Overlay: {result.Verification.OverallStatus}");
Console.WriteLine($" Source: {result.Verification.Source}");
if (result.Verification.Determinism is not null)
{
var score = result.Verification.Determinism.OverallScore.HasValue
? result.Verification.Determinism.OverallScore.Value.ToString("0.000")
: "n/a";
var threshold = result.Verification.Determinism.Threshold.HasValue
? result.Verification.Determinism.Threshold.Value.ToString("0.000")
: "n/a";
Console.WriteLine($" Determinism: score={score}, threshold={threshold}, status={result.Verification.Determinism.Status}");
}
if (result.Verification.Artifacts.Count == 0)
{
Console.WriteLine(" Artifacts: (none)");
}
else
{
Console.WriteLine(" Artifacts:");
foreach (var artifact in result.Verification.Artifacts)
{
Console.WriteLine(
$" - {artifact.Artifact}: hash={FormatVerificationBadge(artifact.HashStatus)}; signature={FormatVerificationBadge(artifact.SignatureStatus)}");
}
}
if (verbose && result.Verification.Warnings.Count > 0)
{
Console.WriteLine(" Warnings:");
foreach (var warning in result.Verification.Warnings)
{
Console.WriteLine($" - {warning}");
}
}
}
private static void WriteSummary(CompareSummary summary, string format, IOutputRenderer renderer, bool verbose)
@@ -418,6 +578,135 @@ internal static class CompareCommandBuilder
_ => "none"
};
}
private static string FormatVerificationBadge(string state)
{
return state.ToLowerInvariant() switch
{
"pass" => "PASS",
"fail" => "FAIL",
"warning" => "WARN",
_ => "UNKNOWN"
};
}
/// <summary>
/// Resolves the baseline digest using the specified strategy.
/// SPRINT_20260208_029: Baseline selection logic.
/// </summary>
private static async Task<string?> ResolveBaselineAsync(
string? explicitBaseDigest,
string? strategyRaw,
string? artifact,
string? currentVersion,
string? backendUrl,
IBaselineResolver? resolver,
bool verbose,
CancellationToken cancellationToken)
{
// Parse strategy (default to explicit if base is provided, otherwise last-green)
var strategy = ParseStrategy(strategyRaw, explicitBaseDigest);
if (strategy == BaselineStrategy.Explicit)
{
if (string.IsNullOrWhiteSpace(explicitBaseDigest))
{
Console.Error.WriteLine("Error: --base is required when using explicit baseline strategy.");
Console.Error.WriteLine(" Use --baseline-strategy=last-green or --baseline-strategy=previous-release for automatic resolution.");
return null;
}
if (verbose)
{
Console.WriteLine($"Using explicit baseline: {explicitBaseDigest}");
}
return explicitBaseDigest;
}
// For auto-resolution strategies, we need the resolver and artifact
if (resolver is null)
{
Console.Error.WriteLine("Error: Baseline resolution service not available.");
Console.Error.WriteLine(" Use --base with an explicit digest instead.");
return null;
}
if (string.IsNullOrWhiteSpace(artifact))
{
Console.Error.WriteLine($"Error: --artifact is required when using {strategy} baseline strategy.");
return null;
}
var request = new BaselineResolutionRequest
{
ArtifactId = artifact,
Strategy = strategy,
ExplicitDigest = explicitBaseDigest,
CurrentVersion = currentVersion,
BackendUrl = backendUrl
};
if (verbose)
{
Console.WriteLine($"Resolving baseline using {strategy} strategy for artifact: {artifact}");
}
var result = await resolver.ResolveAsync(request, cancellationToken);
if (!result.Success)
{
Console.Error.WriteLine($"Error: Failed to resolve baseline - {result.Error}");
if (!string.IsNullOrWhiteSpace(result.Suggestion))
{
Console.Error.WriteLine($" Suggestion: {result.Suggestion}");
}
// Offer suggestions
var suggestions = await resolver.GetSuggestionsAsync(artifact, cancellationToken);
if (suggestions.Count > 0)
{
Console.Error.WriteLine("\nAvailable baselines:");
foreach (var suggestion in suggestions.Take(5))
{
var status = suggestion.IsPassing ? "[PASS]" : "[FAIL]";
Console.Error.WriteLine($" {status} {suggestion.Digest[..Math.Min(12, suggestion.Digest.Length)]}... - {suggestion.Description}");
}
}
return null;
}
if (verbose)
{
Console.WriteLine($"Resolved baseline: {result.Digest}");
}
return result.Digest;
}
/// <summary>
/// Parses the baseline strategy from the command line.
/// </summary>
private static BaselineStrategy ParseStrategy(string? raw, string? explicitDigest)
{
if (string.IsNullOrWhiteSpace(raw))
{
// Default: explicit if --base provided, otherwise last-green
return string.IsNullOrWhiteSpace(explicitDigest)
? BaselineStrategy.LastGreen
: BaselineStrategy.Explicit;
}
return raw.ToLowerInvariant() switch
{
"explicit" => BaselineStrategy.Explicit,
"last-green" or "lastgreen" => BaselineStrategy.LastGreen,
"previous-release" or "previousrelease" => BaselineStrategy.PreviousRelease,
_ => BaselineStrategy.Explicit
};
}
}
/// <summary>
@@ -445,6 +734,7 @@ public sealed record CompareResult
public string? BaseVerdict { get; init; }
public string? TargetVerdict { get; init; }
public required IReadOnlyList<VulnChange> Vulnerabilities { get; init; }
public CompareVerificationOverlay? Verification { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,582 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Cli.Commands.Compare;
internal static class CompareVerificationOverlayBuilder
{
public static async Task<CompareVerificationOverlay?> BuildAsync(
string? verificationReportPath,
string? reverifyBundlePath,
string? determinismManifestPath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(verificationReportPath)
&& string.IsNullOrWhiteSpace(reverifyBundlePath)
&& string.IsNullOrWhiteSpace(determinismManifestPath))
{
return null;
}
var source = new List<string>();
var warnings = new SortedSet<string>(StringComparer.Ordinal);
var artifacts = new Dictionary<string, ArtifactVerificationBuilder>(StringComparer.Ordinal);
var overallFromReport = "UNKNOWN";
var reverified = false;
if (!string.IsNullOrWhiteSpace(verificationReportPath))
{
source.Add("verification-report");
await ApplyVerificationReportAsync(
verificationReportPath,
artifacts,
warnings,
value => overallFromReport = value,
cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(reverifyBundlePath))
{
source.Add("reverify-bundle");
reverified = true;
await ApplyBundleReverificationAsync(
reverifyBundlePath,
artifacts,
warnings,
cancellationToken).ConfigureAwait(false);
}
CompareDeterminismVerification? determinism = null;
if (!string.IsNullOrWhiteSpace(determinismManifestPath))
{
source.Add("determinism-manifest");
determinism = await ParseDeterminismManifestAsync(
determinismManifestPath,
warnings,
cancellationToken).ConfigureAwait(false);
}
var artifactList = artifacts.Values
.Select(builder => builder.Build())
.OrderBy(static item => item.Artifact, StringComparer.Ordinal)
.ToArray();
var aggregateStatus = ComputeOverallStatus(artifactList, warnings, determinism);
var overallStatus = MergeOverallStatus(overallFromReport, aggregateStatus);
var sourceText = source.Count == 0 ? "none" : string.Join("+", source);
return new CompareVerificationOverlay
{
Source = sourceText,
Reverified = reverified,
OverallStatus = overallStatus,
Artifacts = artifactList,
Determinism = determinism,
Warnings = warnings.ToArray()
};
}
private static async Task ApplyVerificationReportAsync(
string reportPath,
IDictionary<string, ArtifactVerificationBuilder> artifacts,
ISet<string> warnings,
Action<string> setOverallStatus,
CancellationToken cancellationToken)
{
if (!File.Exists(reportPath))
{
warnings.Add($"verification report not found: {reportPath}");
return;
}
try
{
var json = await File.ReadAllTextAsync(reportPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.TryGetProperty("overallStatus", out var overallNode)
&& overallNode.ValueKind == JsonValueKind.String)
{
setOverallStatus(overallNode.GetString() ?? "UNKNOWN");
}
if (!root.TryGetProperty("checks", out var checksNode)
|| checksNode.ValueKind != JsonValueKind.Array)
{
warnings.Add("verification report missing checks array");
return;
}
foreach (var check in checksNode.EnumerateArray())
{
var name = check.TryGetProperty("name", out var nameNode) && nameNode.ValueKind == JsonValueKind.String
? nameNode.GetString() ?? string.Empty
: string.Empty;
var message = check.TryGetProperty("message", out var messageNode) && messageNode.ValueKind == JsonValueKind.String
? messageNode.GetString() ?? string.Empty
: string.Empty;
var passed = check.TryGetProperty("passed", out var passedNode) && passedNode.ValueKind == JsonValueKind.True;
var severity = check.TryGetProperty("severity", out var severityNode) && severityNode.ValueKind == JsonValueKind.String
? severityNode.GetString() ?? string.Empty
: string.Empty;
var status = ToStatus(passed, severity);
if (name.StartsWith("checksum:", StringComparison.OrdinalIgnoreCase))
{
var artifact = NormalizePath(name["checksum:".Length..]);
if (string.IsNullOrWhiteSpace(artifact))
{
continue;
}
GetArtifactBuilder(artifacts, artifact).SetHash(status, message);
continue;
}
if (name.StartsWith("dsse:", StringComparison.OrdinalIgnoreCase))
{
var dssePath = NormalizePath(name["dsse:".Length..]);
if (string.IsNullOrWhiteSpace(dssePath))
{
continue;
}
var artifact = ResolveSignatureArtifact(dssePath, artifacts.Keys);
GetArtifactBuilder(artifacts, artifact).SetSignature(status, message);
}
}
}
catch (Exception ex)
{
warnings.Add($"failed to parse verification report '{reportPath}': {ex.Message}");
}
}
private static async Task ApplyBundleReverificationAsync(
string bundlePath,
IDictionary<string, ArtifactVerificationBuilder> artifacts,
ISet<string> warnings,
CancellationToken cancellationToken)
{
if (!Directory.Exists(bundlePath))
{
warnings.Add($"reverify bundle path is not a directory: {bundlePath}");
return;
}
var manifestPath = Path.Combine(bundlePath, "manifest.json");
if (!File.Exists(manifestPath))
{
warnings.Add($"reverify bundle missing manifest.json: {bundlePath}");
return;
}
try
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(manifestJson);
if (!document.RootElement.TryGetProperty("bundle", out var bundleNode)
|| !bundleNode.TryGetProperty("artifacts", out var artifactsNode)
|| artifactsNode.ValueKind != JsonValueKind.Array)
{
warnings.Add("reverify bundle manifest does not contain bundle.artifacts");
return;
}
foreach (var artifactNode in artifactsNode.EnumerateArray())
{
var artifactPath = artifactNode.TryGetProperty("path", out var pathNode)
&& pathNode.ValueKind == JsonValueKind.String
? NormalizePath(pathNode.GetString() ?? string.Empty)
: string.Empty;
if (string.IsNullOrWhiteSpace(artifactPath))
{
continue;
}
var digest = artifactNode.TryGetProperty("digest", out var digestNode)
&& digestNode.ValueKind == JsonValueKind.String
? digestNode.GetString()
: null;
var builder = GetArtifactBuilder(artifacts, artifactPath);
var fullArtifactPath = Path.Combine(
bundlePath,
artifactPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullArtifactPath))
{
builder.SetHash("fail", $"artifact not found: {artifactPath}");
}
else if (string.IsNullOrWhiteSpace(digest))
{
builder.SetHash("warning", "manifest digest missing");
}
else if (!digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
builder.SetHash("warning", $"unsupported digest algorithm: {digest}");
}
else
{
var expected = digest["sha256:".Length..].Trim().ToLowerInvariant();
var actual = await ComputeSha256Async(fullArtifactPath, cancellationToken).ConfigureAwait(false);
var matches = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
builder.SetHash(matches ? "pass" : "fail", matches ? "hash matches" : "hash mismatch");
}
var dssePath = FindSidecarDssePath(bundlePath, artifactPath);
if (dssePath is null)
{
builder.SetSignature("warning", "dsse sidecar not found");
}
else
{
var signatureStatus = await ReadSignatureStatusAsync(dssePath, cancellationToken).ConfigureAwait(false);
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
}
}
// Ensure standalone DSSE files appear even if not tied to artifact list.
foreach (var dsseFile in Directory.GetFiles(bundlePath, "*.dsse.json", SearchOption.AllDirectories)
.OrderBy(static item => item, StringComparer.Ordinal))
{
var relativePath = NormalizePath(Path.GetRelativePath(bundlePath, dsseFile));
var artifact = ResolveSignatureArtifact(relativePath, artifacts.Keys);
var builder = GetArtifactBuilder(artifacts, artifact);
if (builder.SignatureStatus != "unknown")
{
continue;
}
var signatureStatus = await ReadSignatureStatusAsync(dsseFile, cancellationToken).ConfigureAwait(false);
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
}
}
catch (Exception ex)
{
warnings.Add($"failed to reverify bundle '{bundlePath}': {ex.Message}");
}
}
private static async Task<CompareDeterminismVerification?> ParseDeterminismManifestAsync(
string manifestPath,
ISet<string> warnings,
CancellationToken cancellationToken)
{
if (!File.Exists(manifestPath))
{
warnings.Add($"determinism manifest not found: {manifestPath}");
return null;
}
try
{
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var score = TryReadDouble(root, "overall_score", "overallScore");
var threshold = root.TryGetProperty("thresholds", out var thresholdsNode)
? TryReadDouble(thresholdsNode, "overall_min", "overallMin")
: null;
var status = "unknown";
if (score.HasValue && threshold.HasValue)
{
status = score.Value >= threshold.Value ? "pass" : "fail";
}
else if (score.HasValue)
{
status = "warning";
}
var imageCount = root.TryGetProperty("images", out var imagesNode)
&& imagesNode.ValueKind == JsonValueKind.Array
? imagesNode.GetArrayLength()
: 0;
return new CompareDeterminismVerification
{
ManifestPath = manifestPath,
OverallScore = score,
Threshold = threshold,
Status = status,
ImageCount = imageCount
};
}
catch (Exception ex)
{
warnings.Add($"failed to parse determinism manifest '{manifestPath}': {ex.Message}");
return null;
}
}
private static ArtifactVerificationBuilder GetArtifactBuilder(
IDictionary<string, ArtifactVerificationBuilder> artifacts,
string artifact)
{
if (artifacts.TryGetValue(artifact, out var existing))
{
return existing;
}
var created = new ArtifactVerificationBuilder(artifact);
artifacts[artifact] = created;
return created;
}
private static string ResolveSignatureArtifact(string dssePath, IEnumerable<string> knownArtifacts)
{
var normalized = NormalizePath(dssePath);
if (string.IsNullOrWhiteSpace(normalized))
{
return "unknown";
}
var candidate = normalized.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase)
? normalized[..^10]
: normalized;
foreach (var artifact in knownArtifacts)
{
if (string.Equals(artifact, candidate, StringComparison.OrdinalIgnoreCase))
{
return artifact;
}
}
var candidateName = Path.GetFileNameWithoutExtension(candidate);
foreach (var artifact in knownArtifacts)
{
if (string.Equals(
Path.GetFileNameWithoutExtension(artifact),
candidateName,
StringComparison.OrdinalIgnoreCase))
{
return artifact;
}
}
return candidate;
}
private static string NormalizePath(string value)
{
return value.Replace('\\', '/').Trim();
}
private static string? FindSidecarDssePath(string bundlePath, string artifactPath)
{
var artifactFilePath = Path.Combine(bundlePath, artifactPath.Replace('/', Path.DirectorySeparatorChar));
var candidates = new[]
{
$"{artifactFilePath}.dsse.json",
Path.ChangeExtension(artifactFilePath, ".dsse.json"),
Path.Combine(Path.GetDirectoryName(artifactFilePath) ?? bundlePath, $"{Path.GetFileName(artifactFilePath)}.dsse.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static async Task<(string Status, string Message)> ReadSignatureStatusAsync(string dssePath, CancellationToken cancellationToken)
{
try
{
var json = await File.ReadAllTextAsync(dssePath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("signatures", out var signaturesNode)
|| signaturesNode.ValueKind != JsonValueKind.Array)
{
return ("fail", "signatures array missing");
}
return signaturesNode.GetArrayLength() > 0
? ("pass", "signature(s) present")
: ("fail", "no signatures present");
}
catch (Exception ex)
{
return ("fail", $"failed to parse dsse file: {ex.Message}");
}
}
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
{
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static string ToStatus(bool passed, string severity)
{
if (passed)
{
return "pass";
}
return string.Equals(severity, "warning", StringComparison.OrdinalIgnoreCase)
? "warning"
: "fail";
}
private static double? TryReadDouble(JsonElement node, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
if (!node.TryGetProperty(propertyName, out var property))
{
continue;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out var numeric))
{
return numeric;
}
}
return null;
}
private static string ComputeOverallStatus(
IReadOnlyCollection<CompareArtifactVerification> artifacts,
IReadOnlyCollection<string> warnings,
CompareDeterminismVerification? determinism)
{
if (artifacts.Any(static a => a.HashStatus == "fail" || a.SignatureStatus == "fail")
|| string.Equals(determinism?.Status, "fail", StringComparison.OrdinalIgnoreCase))
{
return "FAILED";
}
if (artifacts.Any(static a => a.HashStatus == "warning" || a.SignatureStatus == "warning")
|| warnings.Count > 0
|| string.Equals(determinism?.Status, "warning", StringComparison.OrdinalIgnoreCase))
{
return "PASSED_WITH_WARNINGS";
}
return "PASSED";
}
private static string MergeOverallStatus(string fromReport, string computed)
{
var reportRank = Rank(fromReport);
var computedRank = Rank(computed);
return reportRank >= computedRank ? NormalizeOverall(fromReport) : NormalizeOverall(computed);
}
private static int Rank(string status)
{
return NormalizeOverall(status) switch
{
"FAILED" => 3,
"PASSED_WITH_WARNINGS" => 2,
"PASSED" => 1,
_ => 0
};
}
private static string NormalizeOverall(string status)
{
return status.Trim().ToUpperInvariant() switch
{
"FAILED" => "FAILED",
"PASSED_WITH_WARNINGS" => "PASSED_WITH_WARNINGS",
"PASSED" => "PASSED",
_ => "UNKNOWN"
};
}
private sealed class ArtifactVerificationBuilder
{
public ArtifactVerificationBuilder(string artifact)
{
Artifact = artifact;
}
public string Artifact { get; }
public string HashStatus { get; private set; } = "unknown";
public string SignatureStatus { get; private set; } = "unknown";
public string? HashMessage { get; private set; }
public string? SignatureMessage { get; private set; }
public void SetHash(string status, string? message)
{
HashStatus = PromoteStatus(HashStatus, status);
HashMessage = message;
}
public void SetSignature(string status, string? message)
{
SignatureStatus = PromoteStatus(SignatureStatus, status);
SignatureMessage = message;
}
public CompareArtifactVerification Build()
{
return new CompareArtifactVerification
{
Artifact = Artifact,
HashStatus = HashStatus,
SignatureStatus = SignatureStatus,
HashMessage = HashMessage,
SignatureMessage = SignatureMessage
};
}
private static string PromoteStatus(string current, string incoming)
{
var currentRank = StatusRank(current);
var incomingRank = StatusRank(incoming);
return incomingRank >= currentRank
? incoming.ToLowerInvariant()
: current.ToLowerInvariant();
}
private static int StatusRank(string status)
{
return status switch
{
_ when string.Equals(status, "fail", StringComparison.OrdinalIgnoreCase) => 3,
_ when string.Equals(status, "warning", StringComparison.OrdinalIgnoreCase) => 2,
_ when string.Equals(status, "pass", StringComparison.OrdinalIgnoreCase) => 1,
_ => 0
};
}
}
}
public sealed record CompareVerificationOverlay
{
public required string Source { get; init; }
public bool Reverified { get; init; }
public required string OverallStatus { get; init; }
public required IReadOnlyList<CompareArtifactVerification> Artifacts { get; init; }
public CompareDeterminismVerification? Determinism { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed record CompareArtifactVerification
{
public required string Artifact { get; init; }
public required string HashStatus { get; init; }
public required string SignatureStatus { get; init; }
public string? HashMessage { get; init; }
public string? SignatureMessage { get; init; }
}
public sealed record CompareDeterminismVerification
{
public required string ManifestPath { get; init; }
public double? OverallScore { get; init; }
public double? Threshold { get; init; }
public required string Status { get; init; }
public int ImageCount { get; init; }
}

View File

@@ -61,7 +61,11 @@ public static class EvidenceCommandGroup
BuildReplayCommand(verboseOption),
BuildProofCommand(verboseOption),
BuildProvenanceCommand(verboseOption),
BuildSealCommand(verboseOption)
BuildSealCommand(verboseOption),
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
EvidenceReferrerCommands.BuildPushReferrerCommand(services, verboseOption, cancellationToken),
EvidenceReferrerCommands.BuildListReferrersCommand(services, verboseOption, cancellationToken)
};
return evidence;

View File

@@ -0,0 +1,565 @@
// -----------------------------------------------------------------------------
// EvidenceReferrerCommands.cs
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
// Task: T1/T2 — push-referrer and list-referrers commands for OCI evidence storage
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Commands;
/// <summary>
/// CLI commands for pushing and listing OCI referrers for evidence storage.
/// Enables `stella evidence push-referrer` and `stella evidence list-referrers`.
/// </summary>
public static class EvidenceReferrerCommands
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Builds the `evidence push-referrer` command.
/// Pushes an evidence artifact as an OCI referrer to a registry.
/// </summary>
public static Command BuildPushReferrerCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image")
{
Description = "OCI image reference to attach the referrer to (e.g., registry/repo@sha256:abc...).",
Required = true
};
var artifactTypeOption = new Option<string>("--artifact-type")
{
Description = "OCI artifact type for the referrer (e.g., application/vnd.stellaops.verdict.attestation.v1+json).",
Required = true
};
var fileOption = new Option<string>("--file")
{
Description = "Path to the evidence artifact file to push as a referrer.",
Required = true
};
var annotationOption = new Option<string[]?>("--annotation")
{
Description = "Key=value annotation to attach to the referrer manifest. Can be specified multiple times."
};
var offlineOption = new Option<bool>("--offline")
{
Description = "Simulate push without connecting to a registry (for offline/air-gap testing)."
};
var command = new Command("push-referrer", "Push an evidence artifact as an OCI referrer to a container registry.")
{
imageOption,
artifactTypeOption,
fileOption,
annotationOption,
offlineOption,
verboseOption
};
command.SetAction(async (parseResult, ct) =>
{
var image = parseResult.GetValue(imageOption)!;
var artifactType = parseResult.GetValue(artifactTypeOption)!;
var filePath = parseResult.GetValue(fileOption)!;
var annotations = parseResult.GetValue(annotationOption);
var offline = parseResult.GetValue(offlineOption);
var verbose = parseResult.GetValue(verboseOption);
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
return await ExecutePushReferrerAsync(
services, image, artifactType, filePath, annotations, offline, verbose, logger, ct);
});
return command;
}
/// <summary>
/// Builds the `evidence list-referrers` command.
/// Lists all OCI referrers attached to an artifact digest.
/// </summary>
public static Command BuildListReferrersCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image")
{
Description = "OCI image reference to list referrers for.",
Required = true
};
var digestOption = new Option<string?>("--digest")
{
Description = "Specific digest to list referrers for. If omitted, resolves from image reference."
};
var artifactTypeOption = new Option<string?>("--artifact-type")
{
Description = "Filter referrers by artifact type."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table (default) or json."
};
formatOption.SetDefaultValue("table");
var offlineOption = new Option<bool>("--offline")
{
Description = "Use simulated data instead of connecting to a registry."
};
var command = new Command("list-referrers", "List all OCI referrers attached to an artifact digest.")
{
imageOption,
digestOption,
artifactTypeOption,
formatOption,
offlineOption,
verboseOption
};
command.SetAction(async (parseResult, ct) =>
{
var image = parseResult.GetValue(imageOption)!;
var digest = parseResult.GetValue(digestOption);
var artifactType = parseResult.GetValue(artifactTypeOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var offline = parseResult.GetValue(offlineOption);
var verbose = parseResult.GetValue(verboseOption);
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
return await ExecuteListReferrersAsync(
services, image, digest, artifactType, format, offline, verbose, logger, ct);
});
return command;
}
// ── Push referrer implementation ───────────────────────────────────
internal static async Task<int> ExecutePushReferrerAsync(
IServiceProvider services,
string image,
string artifactType,
string filePath,
string[]? annotations,
bool offline,
bool verbose,
ILogger logger,
CancellationToken ct)
{
if (!File.Exists(filePath))
{
Console.Error.WriteLine($"Error: file not found: {filePath}");
return 1;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
var fileDigest = ComputeSha256Digest(fileBytes);
var parsedAnnotations = ParseAnnotations(annotations);
if (verbose)
{
logger.LogInformation("Pushing referrer: image={Image}, artifactType={ArtifactType}, file={File}, digest={Digest}, size={Size}",
image, artifactType, filePath, fileDigest, fileBytes.Length);
}
if (offline)
{
return HandleOfflinePush(image, artifactType, filePath, fileDigest, fileBytes.Length, parsedAnnotations);
}
var ociClient = services.GetService<IOciRegistryClient>();
if (ociClient is null)
{
Console.Error.WriteLine("Error: OCI registry client not configured.");
return 1;
}
var reference = ParseImageReference(image);
if (reference is null)
{
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
return 1;
}
try
{
// Resolve the subject digest if not provided
var subjectDigest = reference.Digest;
if (string.IsNullOrEmpty(subjectDigest))
{
subjectDigest = await ociClient.ResolveDigestAsync(reference, ct);
}
// Build the referrer manifest
var manifest = BuildReferrerManifest(artifactType, fileDigest, fileBytes.Length, subjectDigest, parsedAnnotations);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
Console.WriteLine($"Pushed referrer:");
Console.WriteLine($" Subject: {reference.Repository}@{subjectDigest}");
Console.WriteLine($" Artifact: {artifactType}");
Console.WriteLine($" Layer digest: {fileDigest}");
Console.WriteLine($" Layer size: {fileBytes.Length}");
Console.WriteLine($" Manifest: {ComputeSha256Digest(Encoding.UTF8.GetBytes(manifestJson))}");
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push referrer to {Image}", image);
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
// ── List referrers implementation ──────────────────────────────────
internal static async Task<int> ExecuteListReferrersAsync(
IServiceProvider services,
string image,
string? digest,
string? artifactType,
string format,
bool offline,
bool verbose,
ILogger logger,
CancellationToken ct)
{
if (verbose)
{
logger.LogInformation("Listing referrers: image={Image}, digest={Digest}, artifactType={ArtifactType}",
image, digest, artifactType);
}
if (offline)
{
return HandleOfflineList(image, digest, artifactType, format);
}
var ociClient = services.GetService<IOciRegistryClient>();
if (ociClient is null)
{
Console.Error.WriteLine("Error: OCI registry client not configured.");
return 1;
}
var reference = ParseImageReference(image);
if (reference is null)
{
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
return 1;
}
try
{
var resolvedDigest = digest ?? reference.Digest;
if (string.IsNullOrEmpty(resolvedDigest))
{
resolvedDigest = await ociClient.ResolveDigestAsync(reference, ct);
}
var referrers = await ociClient.GetReferrersAsync(
reference.Registry, reference.Repository, resolvedDigest, artifactType, ct);
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = JsonSerializer.Serialize(referrers, SerializerOptions);
Console.WriteLine(json);
}
else
{
RenderReferrersTable(referrers, resolvedDigest);
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list referrers for {Image}", image);
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
// ── Offline handlers ───────────────────────────────────────────────
internal static int HandleOfflinePush(
string image,
string artifactType,
string filePath,
string fileDigest,
int fileSize,
Dictionary<string, string> annotations)
{
var manifest = BuildReferrerManifest(
artifactType, fileDigest, fileSize, "sha256:offline-subject-digest", annotations);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
Console.WriteLine("[offline] Simulated push-referrer:");
Console.WriteLine($" Image: {image}");
Console.WriteLine($" Artifact: {artifactType}");
Console.WriteLine($" File: {filePath}");
Console.WriteLine($" Layer digest: {fileDigest}");
Console.WriteLine($" Layer size: {fileSize}");
Console.WriteLine($" Manifest:");
Console.WriteLine(manifestJson);
return 0;
}
internal static int HandleOfflineList(
string image,
string? digest,
string? artifactType,
string format)
{
var simulatedReferrers = new List<OciReferrerDescriptor>
{
new()
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = OciMediaTypes.VerdictAttestation,
Digest = "sha256:aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb",
Size = 1024,
Annotations = new Dictionary<string, string>
{
["org.opencontainers.image.created"] = "2026-02-08T12:00:00Z",
[OciAnnotations.StellaVerdictDecision] = "PASS"
}
},
new()
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = OciMediaTypes.SbomAttestation,
Digest = "sha256:11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff",
Size = 4096,
Annotations = new Dictionary<string, string>
{
["org.opencontainers.image.created"] = "2026-02-08T12:00:01Z",
[OciAnnotations.StellaSbomDigest] = "sha256:sbom-digest-example"
}
}
};
// Apply artifact type filter
if (!string.IsNullOrEmpty(artifactType))
{
simulatedReferrers = simulatedReferrers
.Where(r => string.Equals(r.ArtifactType, artifactType, StringComparison.Ordinal))
.ToList();
}
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = JsonSerializer.Serialize(simulatedReferrers, SerializerOptions);
Console.WriteLine(json);
}
else
{
var resolvedDigest = digest ?? "sha256:offline-subject-digest";
Console.WriteLine($"[offline] Simulated referrers for {image}:");
RenderReferrersTable(simulatedReferrers, resolvedDigest);
}
return 0;
}
// ── Helpers ─────────────────────────────────────────────────────────
internal static OciImageReference? ParseImageReference(string image)
{
// Format: registry/repository@sha256:digest or registry/repository:tag
var atIdx = image.IndexOf('@');
string? digest = null;
string? tag = null;
string registryRepo;
if (atIdx >= 0)
{
digest = image[(atIdx + 1)..];
registryRepo = image[..atIdx];
}
else
{
var colonIdx = image.LastIndexOf(':');
// Avoid splitting on port numbers (e.g., registry:5000/repo)
if (colonIdx >= 0 && image[(colonIdx + 1)..].All(c => !char.IsDigit(c) || image[(colonIdx + 1)..].Contains('/')))
{
// Simple heuristic: if after : there's no /, it's a tag
var afterColon = image[(colonIdx + 1)..];
if (!afterColon.Contains('/'))
{
tag = afterColon;
registryRepo = image[..colonIdx];
}
else
{
registryRepo = image;
}
}
else
{
registryRepo = image;
}
}
var slashIdx = registryRepo.IndexOf('/');
if (slashIdx < 0) return null;
return new OciImageReference
{
Registry = registryRepo[..slashIdx],
Repository = registryRepo[(slashIdx + 1)..],
Tag = tag,
Digest = digest,
Original = image
};
}
internal static ReferrerManifest BuildReferrerManifest(
string artifactType,
string layerDigest,
int layerSize,
string subjectDigest,
Dictionary<string, string> annotations)
{
return new ReferrerManifest
{
SchemaVersion = 2,
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = artifactType,
Config = new ManifestDescriptor
{
MediaType = "application/vnd.oci.empty.v1+json",
Digest = "sha256:44136fa355b311bfa0680e70cf7b5b35e2b5615f2e49a8e9c5c7e2f5f1b1f7d0",
Size = 2
},
Layers =
[
new ManifestDescriptor
{
MediaType = artifactType,
Digest = layerDigest,
Size = layerSize
}
],
Subject = new ManifestDescriptor
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
Digest = subjectDigest,
Size = 0
},
Annotations = annotations.Count > 0 ? annotations : null
};
}
internal static Dictionary<string, string> ParseAnnotations(string[]? annotations)
{
var result = new Dictionary<string, string>();
if (annotations is null) return result;
foreach (var annotation in annotations)
{
var eqIdx = annotation.IndexOf('=');
if (eqIdx > 0)
{
result[annotation[..eqIdx]] = annotation[(eqIdx + 1)..];
}
}
return result;
}
private static string ComputeSha256Digest(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static void RenderReferrersTable(IReadOnlyList<OciReferrerDescriptor> referrers, string subjectDigest)
{
Console.WriteLine($"Subject: {subjectDigest}");
Console.WriteLine($"Referrers: {referrers.Count}");
Console.WriteLine();
if (referrers.Count == 0)
{
Console.WriteLine(" (no referrers found)");
return;
}
Console.WriteLine($" {"ARTIFACT TYPE",-55} {"DIGEST",-20} {"SIZE",8}");
Console.WriteLine($" {new string('-', 55)} {new string('-', 20)} {new string('-', 8)}");
foreach (var r in referrers)
{
var shortDigest = r.Digest.Length > 19 ? r.Digest[..19] + "…" : r.Digest;
Console.WriteLine($" {r.ArtifactType ?? "(unknown)",-55} {shortDigest,-20} {r.Size,8}");
}
}
// ── Internal DTOs ──────────────────────────────────────────────────
internal sealed record ReferrerManifest
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("artifactType")]
public string? ArtifactType { get; init; }
[JsonPropertyName("config")]
public ManifestDescriptor? Config { get; init; }
[JsonPropertyName("layers")]
public List<ManifestDescriptor> Layers { get; init; } = [];
[JsonPropertyName("subject")]
public ManifestDescriptor? Subject { get; init; }
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}
internal sealed record ManifestDescriptor
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; init; }
}
}

View File

@@ -0,0 +1,85 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "stellaops/cli/unknowns-export.schema.json",
"title": "StellaOps Unknowns Export Envelope",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"exportedAt",
"itemCount",
"items"
],
"properties": {
"schemaVersion": {
"type": "string",
"description": "Version marker for forward-compatible parsing.",
"examples": ["unknowns.export.v1"]
},
"exportedAt": {
"type": "string",
"format": "date-time",
"description": "Deterministic export timestamp derived from payload contents."
},
"itemCount": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"items": {
"$ref": "#/$defs/unknown"
}
}
},
"$defs": {
"unknown": {
"type": "object",
"additionalProperties": true,
"required": [
"id",
"packageId",
"packageVersion",
"band",
"score",
"reasonCode",
"reasonCodeShort",
"firstSeenAt",
"lastEvaluatedAt"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"packageId": {
"type": "string"
},
"packageVersion": {
"type": "string"
},
"band": {
"type": "string",
"enum": ["hot", "warm", "cold"]
},
"score": {
"type": "number"
},
"reasonCode": {
"type": "string"
},
"reasonCodeShort": {
"type": "string"
},
"firstSeenAt": {
"type": "string",
"format": "date-time"
},
"lastEvaluatedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}

View File

@@ -23,6 +23,8 @@ namespace StellaOps.Cli.Commands;
/// </summary>
public static class UnknownsCommandGroup
{
private const string DefaultUnknownsExportSchemaVersion = "unknowns.export.v1";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
@@ -395,6 +397,13 @@ public static class UnknownsCommandGroup
Description = "Output format: json, csv, ndjson"
};
formatOption.SetDefaultValue("json");
formatOption.FromAmong("json", "csv", "ndjson");
var schemaVersionOption = new Option<string>("--schema-version")
{
Description = "Schema version to stamp into exported artifacts."
};
schemaVersionOption.SetDefaultValue(DefaultUnknownsExportSchemaVersion);
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
@@ -404,6 +413,7 @@ public static class UnknownsCommandGroup
var exportCommand = new Command("export", "Export unknowns with fingerprints and triggers for offline analysis");
exportCommand.Add(bandOption);
exportCommand.Add(formatOption);
exportCommand.Add(schemaVersionOption);
exportCommand.Add(outputOption);
exportCommand.Add(verboseOption);
@@ -411,10 +421,11 @@ public static class UnknownsCommandGroup
{
var band = parseResult.GetValue(bandOption);
var format = parseResult.GetValue(formatOption) ?? "json";
var schemaVersion = parseResult.GetValue(schemaVersionOption) ?? DefaultUnknownsExportSchemaVersion;
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return await HandleExportAsync(services, band, format, output, verbose, cancellationToken);
return await HandleExportAsync(services, band, format, schemaVersion, output, verbose, cancellationToken);
});
return exportCommand;
@@ -1009,6 +1020,7 @@ public static class UnknownsCommandGroup
IServiceProvider services,
string? band,
string format,
string schemaVersion,
string? outputPath,
bool verbose,
CancellationToken ct)
@@ -1052,9 +1064,15 @@ public static class UnknownsCommandGroup
// Deterministic ordering by band priority, then score descending
var sorted = result.Items
.OrderBy(u => u.Band switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
.OrderBy(u => u.Band.ToLowerInvariant() switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
.ThenByDescending(u => u.Score)
.ThenBy(u => u.PackageId, StringComparer.Ordinal)
.ThenBy(u => u.PackageVersion, StringComparer.Ordinal)
.ThenBy(u => u.Id)
.ToList();
var exportedAt = sorted.Count == 0
? DateTimeOffset.UnixEpoch
: sorted.Max(u => u.LastEvaluatedAt).ToUniversalTime();
TextWriter writer = outputPath is not null
? new StreamWriter(outputPath)
@@ -1065,17 +1083,33 @@ public static class UnknownsCommandGroup
switch (format.ToLowerInvariant())
{
case "csv":
await WriteCsvAsync(writer, sorted);
await WriteCsvAsync(writer, sorted, schemaVersion, exportedAt);
break;
case "ndjson":
await writer.WriteLineAsync(JsonSerializer.Serialize(new
{
schemaVersion,
exportedAt,
itemCount = sorted.Count
}, JsonOptions));
foreach (var item in sorted)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(item, JsonOptions));
await writer.WriteLineAsync(JsonSerializer.Serialize(new
{
schemaVersion,
item
}, JsonOptions));
}
break;
case "json":
default:
await writer.WriteLineAsync(JsonSerializer.Serialize(sorted, JsonOptions));
await writer.WriteLineAsync(JsonSerializer.Serialize(new UnknownsExportEnvelope
{
SchemaVersion = schemaVersion,
ExportedAt = exportedAt,
ItemCount = sorted.Count,
Items = sorted
}, JsonOptions));
break;
}
}
@@ -1102,8 +1136,13 @@ public static class UnknownsCommandGroup
}
}
private static async Task WriteCsvAsync(TextWriter writer, IReadOnlyList<UnknownDto> items)
private static async Task WriteCsvAsync(
TextWriter writer,
IReadOnlyList<UnknownDto> items,
string schemaVersion,
DateTimeOffset exportedAt)
{
await writer.WriteLineAsync($"# schema_version={schemaVersion}; exported_at={exportedAt:O}; item_count={items.Count}");
// CSV header
await writer.WriteLineAsync("id,package_id,package_version,band,score,reason_code,fingerprint_id,first_seen_at,last_evaluated_at");
@@ -1590,6 +1629,14 @@ public static class UnknownsCommandGroup
public int TotalCount { get; init; }
}
private sealed record UnknownsExportEnvelope
{
public string SchemaVersion { get; init; } = DefaultUnknownsExportSchemaVersion;
public DateTimeOffset ExportedAt { get; init; }
public int ItemCount { get; init; }
public IReadOnlyList<UnknownDto> Items { get; init; } = [];
}
private sealed record UnknownDto
{
public Guid Id { get; init; }

View File

@@ -56,6 +56,12 @@ public sealed class StellaOpsCliOptions
/// Directory containing offline kits when in offline mode.
/// </summary>
public string? OfflineKitDirectory { get; set; }
/// <summary>
/// Default tenant identifier for multi-tenant operations.
/// Falls back to "default" when not specified.
/// </summary>
public string? DefaultTenant { get; set; }
}
public sealed class StellaOpsCliAuthorityOptions

View File

@@ -211,6 +211,9 @@ internal static class Program
// CLI-FORENSICS-54-001: Forensic verifier (local only, no HTTP)
services.AddSingleton<IForensicVerifier, ForensicVerifier>();
// SPRINT_20260208_029: Baseline resolver for compare commands
services.AddSingleton<IBaselineResolver, BaselineResolver>();
// CLI-FORENSICS-54-002: Attestation reader (local only, no HTTP)
services.AddSingleton<IAttestationReader, AttestationReader>();

View File

@@ -0,0 +1,329 @@
// -----------------------------------------------------------------------------
// BaselineResolver.cs
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
// Description: Resolves baseline snapshots using configurable strategies.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using System.Net.Http.Json;
using System.Text.Json;
namespace StellaOps.Cli.Services;
/// <summary>
/// Resolves baseline snapshots using configurable strategies.
/// </summary>
internal sealed class BaselineResolver : IBaselineResolver
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IForensicSnapshotClient _forensicClient;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<BaselineResolver> _logger;
private readonly TimeProvider _timeProvider;
public BaselineResolver(
IForensicSnapshotClient forensicClient,
StellaOpsCliOptions options,
ILogger<BaselineResolver> logger,
TimeProvider timeProvider)
{
_forensicClient = forensicClient ?? throw new ArgumentNullException(nameof(forensicClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<BaselineResolutionResult> ResolveAsync(
BaselineResolutionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return request.Strategy switch
{
BaselineStrategy.Explicit => ResolveExplicit(request),
BaselineStrategy.LastGreen => await ResolveLastGreenAsync(request, cancellationToken).ConfigureAwait(false),
BaselineStrategy.PreviousRelease => await ResolvePreviousReleaseAsync(request, cancellationToken).ConfigureAwait(false),
_ => new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: request.Strategy,
Error: $"Unknown baseline strategy: {request.Strategy}")
};
}
public async Task<IReadOnlyList<BaselineSuggestion>> GetSuggestionsAsync(
string artifactId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
var suggestions = new List<BaselineSuggestion>();
var tenant = _options.DefaultTenant ?? "default";
try
{
// Query for passing snapshots
var passingQuery = new ForensicSnapshotListQuery(
Tenant: tenant,
Status: ForensicSnapshotStatus.Ready,
Tags: ["verdict:pass", $"artifact:{SanitizeTag(artifactId)}"],
Limit: 3);
var passingResponse = await _forensicClient
.ListSnapshotsAsync(passingQuery, cancellationToken)
.ConfigureAwait(false);
foreach (var snapshot in passingResponse.Snapshots.OrderByDescending(s => s.CreatedAt))
{
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
var version = ExtractVersionFromTags(snapshot.Tags);
suggestions.Add(new BaselineSuggestion(
Digest: digest,
RecommendedStrategy: BaselineStrategy.LastGreen,
Description: $"Passing snapshot from {snapshot.CreatedAt:yyyy-MM-dd}",
Timestamp: snapshot.CreatedAt,
Version: version,
IsPassing: true));
}
// Query for recent releases (regardless of verdict)
var releaseQuery = new ForensicSnapshotListQuery(
Tenant: tenant,
Status: ForensicSnapshotStatus.Ready,
Tags: [$"artifact:{SanitizeTag(artifactId)}", "release:true"],
Limit: 3);
var releaseResponse = await _forensicClient
.ListSnapshotsAsync(releaseQuery, cancellationToken)
.ConfigureAwait(false);
foreach (var snapshot in releaseResponse.Snapshots.OrderByDescending(s => s.CreatedAt))
{
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
var version = ExtractVersionFromTags(snapshot.Tags);
var isPassing = snapshot.Tags.Any(t =>
t.Equals("verdict:pass", StringComparison.OrdinalIgnoreCase));
// Avoid duplicates
if (suggestions.All(s => s.Digest != digest))
{
suggestions.Add(new BaselineSuggestion(
Digest: digest,
RecommendedStrategy: BaselineStrategy.PreviousRelease,
Description: $"Release {version ?? "unknown"} from {snapshot.CreatedAt:yyyy-MM-dd}",
Timestamp: snapshot.CreatedAt,
Version: version,
IsPassing: isPassing));
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve baseline suggestions for {ArtifactId}", artifactId);
}
return suggestions;
}
private static BaselineResolutionResult ResolveExplicit(BaselineResolutionRequest request)
{
if (string.IsNullOrWhiteSpace(request.ExplicitDigest))
{
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.Explicit,
Error: "Explicit baseline strategy requires a digest via --base option");
}
return new BaselineResolutionResult(
Success: true,
Digest: request.ExplicitDigest,
Strategy: BaselineStrategy.Explicit);
}
private async Task<BaselineResolutionResult> ResolveLastGreenAsync(
BaselineResolutionRequest request,
CancellationToken cancellationToken)
{
var tenant = request.TenantId ?? _options.DefaultTenant ?? "default";
try
{
// Query for the most recent passing snapshot for this artifact
var query = new ForensicSnapshotListQuery(
Tenant: tenant,
Status: ForensicSnapshotStatus.Ready,
Tags: ["verdict:pass", $"artifact:{SanitizeTag(request.ArtifactId)}"],
Limit: 1);
_logger.LogDebug(
"Resolving last-green baseline for artifact {ArtifactId} in tenant {Tenant}",
request.ArtifactId,
tenant);
var response = await _forensicClient
.ListSnapshotsAsync(query, cancellationToken)
.ConfigureAwait(false);
if (response.Snapshots.Count == 0)
{
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.LastGreen,
Suggestion: "No passing baselines found. Try --baseline-strategy=explicit with a known digest.",
Error: $"No passing snapshot found for artifact '{request.ArtifactId}'");
}
var snapshot = response.Snapshots[0];
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
_logger.LogInformation(
"Resolved last-green baseline: {Digest} (from {Date})",
digest,
snapshot.CreatedAt);
return new BaselineResolutionResult(
Success: true,
Digest: digest,
Strategy: BaselineStrategy.LastGreen);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve last-green baseline for {ArtifactId}", request.ArtifactId);
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.LastGreen,
Error: $"Failed to query baseline: {ex.Message}");
}
}
private async Task<BaselineResolutionResult> ResolvePreviousReleaseAsync(
BaselineResolutionRequest request,
CancellationToken cancellationToken)
{
var tenant = request.TenantId ?? _options.DefaultTenant ?? "default";
try
{
// For previous release, we query for releases tagged before the current version
var tags = new List<string>
{
$"artifact:{SanitizeTag(request.ArtifactId)}",
"release:true"
};
var query = new ForensicSnapshotListQuery(
Tenant: tenant,
Status: ForensicSnapshotStatus.Ready,
Tags: tags,
Limit: 10); // Get more to filter by version
_logger.LogDebug(
"Resolving previous-release baseline for artifact {ArtifactId} (current: {CurrentVersion})",
request.ArtifactId,
request.CurrentVersion ?? "unspecified");
var response = await _forensicClient
.ListSnapshotsAsync(query, cancellationToken)
.ConfigureAwait(false);
if (response.Snapshots.Count == 0)
{
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.PreviousRelease,
Suggestion: "No previous releases found. Try --baseline-strategy=explicit with a known digest.",
Error: $"No release snapshots found for artifact '{request.ArtifactId}'");
}
// Order by date descending and pick the first one that's not the current version
var candidates = response.Snapshots
.OrderByDescending(s => s.CreatedAt)
.ToList();
ForensicSnapshotDocument? selectedSnapshot = null;
if (!string.IsNullOrWhiteSpace(request.CurrentVersion))
{
// Find the first snapshot that doesn't match current version
foreach (var snapshot in candidates)
{
var version = ExtractVersionFromTags(snapshot.Tags);
if (!string.Equals(version, request.CurrentVersion, StringComparison.OrdinalIgnoreCase))
{
selectedSnapshot = snapshot;
break;
}
}
}
else
{
// No current version specified, just get the most recent
selectedSnapshot = candidates.FirstOrDefault();
}
if (selectedSnapshot is null)
{
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.PreviousRelease,
Suggestion: "All found releases match current version.",
Error: "No previous release found distinct from current version");
}
var digest = selectedSnapshot.Manifest?.Digest ?? selectedSnapshot.SnapshotId;
var resolvedVersion = ExtractVersionFromTags(selectedSnapshot.Tags);
_logger.LogInformation(
"Resolved previous-release baseline: {Digest} (version {Version}, from {Date})",
digest,
resolvedVersion ?? "unknown",
selectedSnapshot.CreatedAt);
return new BaselineResolutionResult(
Success: true,
Digest: digest,
Strategy: BaselineStrategy.PreviousRelease);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve previous-release baseline for {ArtifactId}", request.ArtifactId);
return new BaselineResolutionResult(
Success: false,
Digest: null,
Strategy: BaselineStrategy.PreviousRelease,
Error: $"Failed to query releases: {ex.Message}");
}
}
private static string SanitizeTag(string value)
{
// Tags may need sanitization for special characters
return Uri.EscapeDataString(value);
}
private static string? ExtractVersionFromTags(IReadOnlyList<string> tags)
{
foreach (var tag in tags)
{
if (tag.StartsWith("version:", StringComparison.OrdinalIgnoreCase))
{
return tag["version:".Length..];
}
}
return null;
}
}

View File

@@ -5,6 +5,7 @@
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models.Chat;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
@@ -118,22 +119,85 @@ internal sealed class ChatClient : IChatClient
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
}
public async Task<ChatConversationListResponse> ListConversationsAsync(
string? tenantId = null,
string? userId = null,
int? limit = null,
CancellationToken cancellationToken = default)
{
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
query.Add($"tenantId={Uri.EscapeDataString(tenantId)}");
}
if (limit is > 0)
{
query.Add($"limit={limit.Value}");
}
var suffix = query.Count == 0 ? string.Empty : "?" + string.Join("&", query);
var url = BuildAdvisoryAiUrl($"/v1/advisory-ai/conversations{suffix}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatConversationListResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Conversation list returned null response.");
}
public async Task<ChatConversationResponse> GetConversationAsync(
string conversationId,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(conversationId))
{
throw new ArgumentException("Conversation id must be provided.", nameof(conversationId));
}
var escapedConversationId = Uri.EscapeDataString(conversationId.Trim());
var url = BuildAdvisoryAiUrl($"/v1/advisory-ai/conversations/{escapedConversationId}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatConversationResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Conversation response returned null payload.");
}
private string BuildUrl(string path)
{
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "http://localhost:5000";
return $"{baseUrl}{path}";
}
private string BuildAdvisoryAiUrl(string path)
{
var advisoryAiBase = string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl)
? _options.BackendUrl
: _options.AdvisoryAiUrl;
var baseUrl = advisoryAiBase?.TrimEnd('/') ?? "http://localhost:5000";
return $"{baseUrl}{path}";
}
private static void AddHeaders(HttpRequestMessage request, string? tenantId, string? userId)
{
if (!string.IsNullOrEmpty(tenantId))
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
request.Headers.TryAddWithoutValidation("X-StellaOps-Tenant", tenantId);
}
if (!string.IsNullOrEmpty(userId))
{
request.Headers.TryAddWithoutValidation("X-User-Id", userId);
request.Headers.TryAddWithoutValidation("X-StellaOps-User", userId);
}
request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N"));

View File

@@ -57,4 +57,22 @@ internal interface IChatClient
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists conversations available for the current tenant/user scope.
/// </summary>
Task<ChatConversationListResponse> ListConversationsAsync(
string? tenantId = null,
string? userId = null,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a single conversation with all turns.
/// </summary>
Task<ChatConversationResponse> GetConversationAsync(
string conversationId,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// IBaselineResolver.cs
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
// Description: Service for resolving baseline snapshots with different strategies.
// -----------------------------------------------------------------------------
namespace StellaOps.Cli.Services;
/// <summary>
/// Strategy for selecting a baseline snapshot.
/// </summary>
public enum BaselineStrategy
{
/// <summary>
/// Explicit baseline - requires a specific digest to be provided.
/// </summary>
Explicit = 0,
/// <summary>
/// Last green - selects the most recent snapshot with a passing verdict.
/// </summary>
LastGreen = 1,
/// <summary>
/// Previous release - resolves the previous release tag from SCM/registry metadata.
/// </summary>
PreviousRelease = 2
}
/// <summary>
/// Result from baseline resolution.
/// </summary>
/// <param name="Success">Whether resolution succeeded.</param>
/// <param name="Digest">The resolved snapshot digest (null if failed).</param>
/// <param name="Strategy">The strategy used for resolution.</param>
/// <param name="Suggestion">Suggested baseline if resolution failed.</param>
/// <param name="Error">Error message if resolution failed.</param>
public sealed record BaselineResolutionResult(
bool Success,
string? Digest,
BaselineStrategy Strategy,
string? Suggestion = null,
string? Error = null);
/// <summary>
/// Request for baseline resolution.
/// </summary>
public sealed record BaselineResolutionRequest
{
/// <summary>
/// The target artifact (PURL, OCI reference, or path).
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Explicit baseline digest (used with Explicit strategy).
/// </summary>
public string? ExplicitDigest { get; init; }
/// <summary>
/// Strategy to use for resolution.
/// </summary>
public required BaselineStrategy Strategy { get; init; }
/// <summary>
/// Current version/tag for context (helps with PreviousRelease strategy).
/// </summary>
public string? CurrentVersion { get; init; }
/// <summary>
/// Tenant ID for scoped queries.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Backend URL override.
/// </summary>
public string? BackendUrl { get; init; }
}
/// <summary>
/// Resolves baseline snapshots using configurable strategies.
/// </summary>
public interface IBaselineResolver
{
/// <summary>
/// Resolve a baseline snapshot based on the specified strategy.
/// </summary>
/// <param name="request">Resolution request with strategy and context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Resolution result with digest or error.</returns>
Task<BaselineResolutionResult> ResolveAsync(
BaselineResolutionRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Get suggestions for baselines when none is specified.
/// </summary>
/// <param name="artifactId">Target artifact identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of suggested baselines with metadata.</returns>
Task<IReadOnlyList<BaselineSuggestion>> GetSuggestionsAsync(
string artifactId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A suggested baseline for comparison.
/// </summary>
public sealed record BaselineSuggestion(
string Digest,
BaselineStrategy RecommendedStrategy,
string Description,
DateTimeOffset? Timestamp,
string? Version,
bool IsPassing);

View File

@@ -398,6 +398,126 @@ internal sealed record ChatToolSettingsUpdate
public List<string>? AllowedTools { get; init; }
}
/// <summary>
/// Conversation list response from AdvisoryAI conversation endpoints.
/// </summary>
internal sealed record ChatConversationListResponse
{
[JsonPropertyName("conversations")]
public List<ChatConversationSummary> Conversations { get; init; } = [];
[JsonPropertyName("totalCount")]
public int TotalCount { get; init; }
}
internal sealed record ChatConversationSummary
{
[JsonPropertyName("conversationId")]
public required string ConversationId { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("turnCount")]
public int TurnCount { get; init; }
[JsonPropertyName("preview")]
public string? Preview { get; init; }
}
internal sealed record ChatConversationResponse
{
[JsonPropertyName("conversationId")]
public required string ConversationId { get; init; }
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("userId")]
public required string UserId { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("turns")]
public List<ChatConversationTurn> Turns { get; init; } = [];
}
internal sealed record ChatConversationTurn
{
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
[JsonPropertyName("role")]
public required string Role { get; init; }
[JsonPropertyName("content")]
public required string Content { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("evidenceLinks")]
public List<ChatConversationEvidenceLink>? EvidenceLinks { get; init; }
[JsonPropertyName("proposedActions")]
public List<ChatConversationProposedAction>? ProposedActions { get; init; }
}
internal sealed record ChatConversationEvidenceLink
{
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("uri")]
public required string Uri { get; init; }
[JsonPropertyName("label")]
public string? Label { get; init; }
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
}
internal sealed record ChatConversationProposedAction
{
[JsonPropertyName("actionType")]
public required string ActionType { get; init; }
[JsonPropertyName("label")]
public required string Label { get; init; }
[JsonPropertyName("policyGate")]
public string? PolicyGate { get; init; }
[JsonPropertyName("requiresConfirmation")]
public bool RequiresConfirmation { get; init; }
}
internal sealed record ChatConversationExport
{
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
[JsonPropertyName("userId")]
public string? UserId { get; init; }
[JsonPropertyName("conversationCount")]
public int ConversationCount { get; init; }
[JsonPropertyName("conversations")]
public List<ChatConversationResponse> Conversations { get; init; } = [];
}
/// <summary>
/// Error response from chat API.
/// </summary>

View File

@@ -90,6 +90,7 @@
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Timestamping/StellaOps.Attestor.Timestamping.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.TrustRepo/StellaOps.Attestor.TrustRepo.csproj" />

View File

@@ -57,3 +57,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| TASK-032-005 | BLOCKED | Docs delivered; validation blocked pending stable API filters. |
| TASK-033-007 | DONE | Updated CLI compatibility shims; CLI + plugins build (SPRINT_20260120_033). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). |
| SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). |
| SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08).

View File

@@ -0,0 +1,342 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
using StellaOps.PolicyDsl;
using System.CommandLine;
using System.Text.Json;
using System.Text;
namespace StellaOps.Cli.Plugins.Policy;
/// <summary>
/// CLI plugin module for policy DSL commands.
/// Provides 'stella policy lint', 'stella policy compile', and 'stella policy simulate'.
/// </summary>
public sealed class PolicyCliCommandModule : ICliCommandModule
{
public string Name => "stellaops.cli.plugins.policy";
public bool IsAvailable(IServiceProvider services) => true;
public void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(verboseOption);
root.Add(BuildPolicyCommand(services, verboseOption, options, cancellationToken));
}
private static Command BuildPolicyCommand(
IServiceProvider services,
Option<bool> verboseOption,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var policy = new Command("policy", "Policy DSL operations: lint, compile, and simulate.");
policy.Add(BuildLintCommand(services, verboseOption));
policy.Add(BuildCompileCommand(services, verboseOption));
policy.Add(BuildSimulateCommand(services, verboseOption));
return policy;
}
private static Command BuildLintCommand(
IServiceProvider services,
Option<bool> verboseOption)
{
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to lint.");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output format: text, json. Default: text"
};
var lintCommand = new Command("lint", "Lint a policy DSL file for syntax and semantic errors.")
{
fileArg,
outputOption
};
lintCommand.SetHandler(async (file, output, verbose) =>
{
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
if (!file.Exists)
{
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
Environment.ExitCode = 1;
return;
}
var source = await File.ReadAllTextAsync(file.FullName);
var result = PolicyParser.Parse(source);
var outputFormat = output?.ToLowerInvariant() ?? "text";
if (outputFormat == "json")
{
var jsonResult = new
{
file = file.FullName,
success = !result.Diagnostics.Any(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error),
diagnostics = result.Diagnostics.Select(d => new
{
severity = d.Severity.ToString().ToLowerInvariant(),
code = d.Code,
message = d.Message,
path = d.Path,
location = d.Location is not null ? new
{
line = d.Location.Line,
column = d.Location.Column
} : null
})
};
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
var errors = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error).ToList();
var warnings = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Warning).ToList();
var infos = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Info).ToList();
if (errors.Count == 0 && warnings.Count == 0)
{
Console.WriteLine($"✓ {file.Name}: No issues found.");
}
else
{
Console.WriteLine($"Linting {file.Name}:");
foreach (var diag in result.Diagnostics.OrderBy(d => d.Location?.Line ?? 0))
{
var symbol = diag.Severity switch
{
StellaOps.Policy.PolicyIssueSeverity.Error => "✗",
StellaOps.Policy.PolicyIssueSeverity.Warning => "⚠",
_ => ""
};
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
Console.WriteLine($" {symbol} [{diag.Code}]{location}: {diag.Message}");
}
Console.WriteLine();
Console.WriteLine($"Summary: {errors.Count} error(s), {warnings.Count} warning(s), {infos.Count} info(s)");
}
if (errors.Count > 0)
{
Environment.ExitCode = 1;
}
}
}, fileArg, outputOption, verboseOption);
return lintCommand;
}
private static Command BuildCompileCommand(
IServiceProvider services,
Option<bool> verboseOption)
{
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to compile.");
var outputOption = new Option<FileInfo?>("--output", new[] { "-o" })
{
Description = "Output path for compiled IR (.json). Default: stdout"
};
var checksumOnlyOption = new Option<bool>("--checksum-only")
{
Description = "Only output the deterministic checksum."
};
var compileCommand = new Command("compile", "Compile a policy DSL file to intermediate representation.")
{
fileArg,
outputOption,
checksumOnlyOption
};
compileCommand.SetHandler(async (file, output, checksumOnly, verbose) =>
{
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
if (!file.Exists)
{
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
Environment.ExitCode = 1;
return;
}
var source = await File.ReadAllTextAsync(file.FullName);
var compiler = new PolicyCompiler();
var result = compiler.Compile(source);
if (!result.Success)
{
Console.Error.WriteLine($"Compilation failed for {file.Name}:");
foreach (var diag in result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
{
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
}
Environment.ExitCode = 1;
return;
}
if (checksumOnly)
{
Console.WriteLine(result.Checksum);
return;
}
var irBytes = PolicyIrSerializer.Serialize(result.Document!);
var irJson = Encoding.UTF8.GetString(irBytes.AsSpan());
if (output is not null)
{
await File.WriteAllTextAsync(output.FullName, irJson);
Console.WriteLine($"✓ Compiled {file.Name} -> {output.Name}");
Console.WriteLine($" Checksum: {result.Checksum}");
}
else
{
Console.WriteLine(irJson);
}
}, fileArg, outputOption, checksumOnlyOption, verboseOption);
return compileCommand;
}
private static Command BuildSimulateCommand(
IServiceProvider services,
Option<bool> verboseOption)
{
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file.");
var signalsOption = new Option<FileInfo?>("--signals", new[] { "-s" })
{
Description = "Path to signals context JSON file."
};
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output format: text, json. Default: text"
};
var simulateCommand = new Command("simulate", "Simulate policy evaluation with given signal context.")
{
fileArg,
signalsOption,
outputOption
};
simulateCommand.SetHandler(async (file, signals, output, verbose) =>
{
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
if (!file.Exists)
{
Console.Error.WriteLine($"Error: Policy file not found: {file.FullName}");
Environment.ExitCode = 1;
return;
}
var source = await File.ReadAllTextAsync(file.FullName);
var factory = new PolicyEngineFactory();
var engineResult = factory.CreateFromSource(source);
if (engineResult.Engine is null)
{
Console.Error.WriteLine($"Compilation failed. Run 'stella policy lint' for details.");
foreach (var diag in engineResult.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
{
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
}
Environment.ExitCode = 1;
return;
}
// Load signals context
SignalContext signalContext;
if (signals is not null && signals.Exists)
{
var signalsJson = await File.ReadAllTextAsync(signals.FullName);
var signalsDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(signalsJson);
signalContext = new SignalContext(signalsDict ?? new Dictionary<string, object?>());
}
else
{
signalContext = new SignalContext();
}
// Run simulation
var engine = engineResult.Engine;
var evaluationResult = engine.Evaluate(signalContext);
var outputFormat = output?.ToLowerInvariant() ?? "text";
var verdict = evaluationResult.MatchedRules.Length > 0 ? "match" : "no-match";
if (outputFormat == "json")
{
var jsonResult = new
{
policy = evaluationResult.PolicyName,
policyChecksum = evaluationResult.PolicyChecksum,
verdict,
matchedRules = evaluationResult.MatchedRules,
actions = evaluationResult.Actions.Select(a => new
{
ruleName = a.RuleName,
action = a.Action.ActionName,
wasElseBranch = a.WasElseBranch
})
};
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
Console.WriteLine($"Policy: {evaluationResult.PolicyName}");
Console.WriteLine($"Checksum: {evaluationResult.PolicyChecksum}");
Console.WriteLine();
if (evaluationResult.MatchedRules.Length > 0)
{
Console.WriteLine($"✓ Matched Rules ({evaluationResult.MatchedRules.Length}):");
foreach (var rule in evaluationResult.MatchedRules)
{
Console.WriteLine($" - {rule}");
}
}
else
{
Console.WriteLine("No rules matched.");
}
if (evaluationResult.Actions.Length > 0)
{
Console.WriteLine();
Console.WriteLine($"Actions ({evaluationResult.Actions.Length}):");
foreach (var action in evaluationResult.Actions)
{
var branch = action.WasElseBranch ? " (else)" : "";
Console.WriteLine($" - [{action.RuleName}]{branch}: {action.Action.ActionName}");
}
}
}
}, fileArg, signalsOption, outputOption, verboseOption);
return simulateCommand;
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- StellaOps.Cli.Plugins.Policy: CLI plugin for policy DSL operations (lint, compile, simulate) -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Policy\'))</PluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
<ProjectReference Include="..\..\..\Policy\StellaOps.PolicyDsl\StellaOps.PolicyDsl.csproj" />
</ItemGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<MakeDir Directories="$(PluginOutputDirectory)" />
<ItemGroup>
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
</ItemGroup>
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,217 @@
using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands.Advise;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.AdviseParity.Tests;
public sealed class AdviseParityIsolationTests : IDisposable
{
private readonly string _tempRoot;
public AdviseParityIsolationTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"advise-parity-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
[Fact]
public async Task Ask_WithFile_ProcessesBatchQueriesAsJson()
{
var chatClient = new FakeChatClient();
chatClient.QueryResponseFactory = request => CreateQueryResponse($"resp-{request.Query.Replace(' ', '-')}", request.Query);
var services = new ServiceCollection()
.AddSingleton<IChatClient>(chatClient)
.BuildServiceProvider();
var options = new StellaOpsCliOptions();
var command = AdviseChatCommandGroup.BuildAskCommand(
services,
options,
new Option<bool>("--verbose"),
CancellationToken.None);
var root = new RootCommand { command };
var batchPath = Path.Combine(_tempRoot, "queries.jsonl");
await File.WriteAllTextAsync(
batchPath,
"""
{"query":"first question"}
"second question"
"""
);
var output = new StringWriter();
var original = Console.Out;
try
{
Console.SetOut(output);
await root.Parse($"ask --file \"{batchPath}\" --format json").InvokeAsync();
}
finally
{
Console.SetOut(original);
}
var json = output.ToString();
Assert.Contains("\"count\": 2", json, StringComparison.Ordinal);
Assert.Contains("\"query\": \"first question\"", json, StringComparison.Ordinal);
Assert.Contains("\"query\": \"second question\"", json, StringComparison.Ordinal);
Assert.Equal(2, chatClient.QueryCalls.Count);
}
[Fact]
public async Task Export_WithoutConversationId_ListsAndExportsSortedConversations()
{
var chatClient = new FakeChatClient();
chatClient.ListResponse = new ChatConversationListResponse
{
TotalCount = 2,
Conversations =
[
new ChatConversationSummary
{
ConversationId = "conv-b",
CreatedAt = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-01-02T01:00:00Z"),
TurnCount = 1
},
new ChatConversationSummary
{
ConversationId = "conv-a",
CreatedAt = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-01-01T01:00:00Z"),
TurnCount = 1
}
]
};
chatClient.ConversationById["conv-a"] = CreateConversation("conv-a", "Tenant-1", "User-1", "hello a");
chatClient.ConversationById["conv-b"] = CreateConversation("conv-b", "Tenant-1", "User-1", "hello b");
var services = new ServiceCollection()
.AddSingleton<IChatClient>(chatClient)
.BuildServiceProvider();
var options = new StellaOpsCliOptions();
var command = AdviseChatCommandGroup.BuildExportCommand(
services,
options,
new Option<bool>("--verbose"),
CancellationToken.None);
var root = new RootCommand { command };
var output = new StringWriter();
var original = Console.Out;
try
{
Console.SetOut(output);
await root.Parse("export --format json --tenant tenant-1 --user user-1").InvokeAsync();
}
finally
{
Console.SetOut(original);
}
var json = output.ToString();
using var document = JsonDocument.Parse(json);
var rootNode = document.RootElement;
Assert.Equal(2, rootNode.GetProperty("conversationCount").GetInt32());
var conversations = rootNode.GetProperty("conversations");
Assert.Equal("conv-a", conversations[0].GetProperty("conversationId").GetString());
Assert.Equal("conv-b", conversations[1].GetProperty("conversationId").GetString());
Assert.Equal(2, chatClient.GetConversationCalls.Count);
}
private static ChatQueryResponse CreateQueryResponse(string responseId, string summary)
{
return new ChatQueryResponse
{
ResponseId = responseId,
Intent = "triage",
GeneratedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
Summary = summary,
Confidence = new ChatConfidence
{
Overall = 0.9,
EvidenceQuality = 0.8,
ModelCertainty = 0.85
}
};
}
private static ChatConversationResponse CreateConversation(string id, string tenant, string user, string content)
{
return new ChatConversationResponse
{
ConversationId = id,
TenantId = tenant,
UserId = user,
CreatedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-01-15T09:31:00Z"),
Turns =
[
new ChatConversationTurn
{
TurnId = $"{id}-1",
Role = "user",
Content = content,
Timestamp = DateTimeOffset.Parse("2026-01-15T09:30:00Z")
}
]
};
}
private sealed class FakeChatClient : IChatClient
{
public List<ChatQueryRequest> QueryCalls { get; } = [];
public List<string> GetConversationCalls { get; } = [];
public Func<ChatQueryRequest, ChatQueryResponse>? QueryResponseFactory { get; set; }
public ChatConversationListResponse ListResponse { get; set; } = new();
public Dictionary<string, ChatConversationResponse> ConversationById { get; } = new(StringComparer.Ordinal);
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
{
QueryCalls.Add(request);
return Task.FromResult(QueryResponseFactory?.Invoke(request) ?? CreateQueryResponse("resp-default", request.Query));
}
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
=> Task.FromResult(ListResponse);
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
{
GetConversationCalls.Add(conversationId);
return Task.FromResult(ConversationById[conversationId]);
}
}
}

View File

@@ -0,0 +1,104 @@
using System.CommandLine;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Configuration
{
public sealed class StellaOpsCliOptions
{
public AdvisoryAiOptions AdvisoryAi { get; } = new();
public sealed class AdvisoryAiOptions
{
public bool Configured { get; set; } = true;
public bool HasConfiguredProvider() => Configured;
}
}
}
namespace StellaOps.Cli.Services.Chat
{
internal class ChatException : Exception
{
public ChatException(string message) : base(message)
{
}
}
internal sealed class ChatGuardrailException : ChatException
{
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null) : base(message)
{
ErrorResponse = errorResponse;
}
public ChatErrorResponse? ErrorResponse { get; }
}
internal sealed class ChatToolDeniedException : ChatException
{
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null) : base(message)
{
}
}
internal sealed class ChatQuotaExceededException : ChatException
{
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null) : base(message)
{
}
}
internal sealed class ChatServiceUnavailableException : ChatException
{
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null) : base(message)
{
}
}
internal sealed class ChatClient : IChatClient
{
public ChatClient(HttpClient httpClient, StellaOps.Cli.Configuration.StellaOpsCliOptions options)
{
}
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
namespace System.CommandLine
{
// Compatibility shims for the API shape expected by AdviseChatCommandGroup.
public static class OptionCompatExtensions
{
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
{
return option;
}
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
{
return option;
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<RootNamespace>StellaOps.Cli.AdviseParity.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\AdviseChatCommandGroup.cs" Link="Commands\AdviseChatCommandGroup.cs" />
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\ChatRenderer.cs" Link="Commands\ChatRenderer.cs" />
<Compile Include="..\..\StellaOps.Cli\Services\Chat\IChatClient.cs" Link="Services\IChatClient.cs" />
<Compile Include="..\..\StellaOps.Cli\Services\Models\Chat\ChatModels.cs" Link="Services\ChatModels.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,151 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cli.Commands.Compare;
namespace StellaOps.Cli.CompareOverlay.Tests;
public sealed class CompareVerificationOverlayBuilderIsolationTests : IDisposable
{
private readonly string _tempRoot;
public CompareVerificationOverlayBuilderIsolationTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-isolated-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
[Fact]
public async Task BuildAsync_ParsesVerificationReportChecks()
{
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
await File.WriteAllTextAsync(
reportPath,
"""
{
"overallStatus": "PASSED_WITH_WARNINGS",
"checks": [
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "error" }
]
}
""");
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
reportPath,
reverifyBundlePath: null,
determinismManifestPath: null,
CancellationToken.None);
Assert.NotNull(overlay);
Assert.Equal("verification-report", overlay.Source);
Assert.Equal("FAILED", overlay.OverallStatus);
var artifact = Assert.Single(overlay.Artifacts);
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
Assert.Equal("pass", artifact.HashStatus);
Assert.Equal("fail", artifact.SignatureStatus);
}
[Fact]
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
{
var bundleDir = Path.Combine(_tempRoot, "bundle");
var inputsDir = Path.Combine(bundleDir, "inputs");
Directory.CreateDirectory(inputsDir);
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
await File.WriteAllTextAsync(
Path.Combine(bundleDir, "manifest.json"),
$$"""
{
"bundle": {
"artifacts": [
{
"path": "inputs/sbom.cdx.json",
"digest": "sha256:{{digest}}"
}
]
}
}
""");
await File.WriteAllTextAsync(
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
"""
{
"payloadType": "application/vnd.in-toto+json",
"signatures": [
{ "keyid": "test-key", "sig": "dGVzdA==" }
]
}
""");
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath: null,
reverifyBundlePath: bundleDir,
determinismManifestPath: null,
CancellationToken.None);
Assert.NotNull(overlay);
Assert.True(overlay.Reverified);
Assert.Equal("reverify-bundle", overlay.Source);
var artifact = Assert.Single(overlay.Artifacts);
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
Assert.Equal("pass", artifact.HashStatus);
Assert.Equal("pass", artifact.SignatureStatus);
Assert.Equal("PASSED", overlay.OverallStatus);
}
[Fact]
public async Task BuildAsync_AttachesDeterminismManifestSummary()
{
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
await File.WriteAllTextAsync(
manifestPath,
"""
{
"overall_score": 0.973,
"thresholds": {
"overall_min": 0.950
},
"images": [
{ "digest": "sha256:aaa" },
{ "digest": "sha256:bbb" }
]
}
""");
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath: null,
reverifyBundlePath: null,
determinismManifestPath: manifestPath,
CancellationToken.None);
Assert.NotNull(overlay);
Assert.NotNull(overlay.Determinism);
Assert.Equal("determinism-manifest", overlay.Source);
Assert.Equal(0.973, overlay.Determinism.OverallScore);
Assert.Equal(0.950, overlay.Determinism.Threshold);
Assert.Equal("pass", overlay.Determinism.Status);
Assert.Equal(2, overlay.Determinism.ImageCount);
Assert.Equal("PASSED", overlay.OverallStatus);
}
private static string ComputeSha256Hex(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<RootNamespace>StellaOps.Cli.CompareOverlay.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\StellaOps.Cli\Commands\Compare\CompareVerificationOverlayBuilder.cs" Link="Compare\CompareVerificationOverlayBuilder.cs" />
</ItemGroup>
</Project>

View File

@@ -2,12 +2,17 @@
// Licensed under the BUSL-1.1 license.
using System;
using System.CommandLine;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands.Advise;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
using Xunit;
@@ -252,6 +257,124 @@ public sealed class AdviseChatCommandTests
Assert.Contains("Query: What vulnerabilities affect my image?", output);
}
[Fact]
public async Task RenderConversationExport_Json_RendersConversationsPayload()
{
var export = CreateSampleConversationExport();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
await ChatRenderer.RenderConversationExportAsync(export, ChatOutputFormat.Json, writer, CancellationToken.None);
var output = sb.ToString();
Assert.Contains("\"conversationCount\"", output);
Assert.Contains("\"conv-001\"", output);
Assert.Contains("\"turns\"", output);
}
[Fact]
public void BuildAskCommand_AllowsFileOptionWithoutQuery()
{
var services = new ServiceCollection().BuildServiceProvider();
var command = AdviseChatCommandGroup.BuildAskCommand(
services,
new StellaOpsCliOptions(),
new Option<bool>("--verbose"),
CancellationToken.None);
var parseResult = command.Parse("--file queries.jsonl");
Assert.Empty(parseResult.Errors);
}
[Fact]
public void BuildExportCommand_HasExpectedOptions()
{
var services = new ServiceCollection().BuildServiceProvider();
var command = AdviseChatCommandGroup.BuildExportCommand(
services,
new StellaOpsCliOptions(),
new Option<bool>("--verbose"),
CancellationToken.None);
Assert.Equal("export", command.Name);
Assert.Contains(command.Options, static option => option.Name == "--conversation-id");
Assert.Contains(command.Options, static option => option.Name == "--limit");
Assert.Contains(command.Options, static option => option.Name == "--format");
Assert.Contains(command.Options, static option => option.Name == "--output");
}
[Fact]
public async Task AskCommand_FileBatch_InvokesClientAndWritesJson()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-batch-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var batchPath = Path.Combine(tempDir, "queries.jsonl");
var outputPath = Path.Combine(tempDir, "output.json");
await File.WriteAllTextAsync(batchPath, """
{"query":"What changed in stage?"}
"List high severity CVEs"
""");
var fakeClient = new FakeChatClient();
var services = new ServiceCollection()
.AddSingleton<IChatClient>(fakeClient)
.BuildServiceProvider();
var options = new StellaOpsCliOptions
{
AdvisoryAi = new StellaOpsCliAdvisoryAiOptions
{
Enabled = true,
OpenAi = new StellaOpsCliLlmProviderOptions { ApiKey = "test-key" }
}
};
var command = AdviseChatCommandGroup.BuildAskCommand(
services,
options,
new Option<bool>("--verbose"),
CancellationToken.None);
var parseResult = command.Parse($"--file \"{batchPath}\" --format json --output \"{outputPath}\"");
var exitCode = await parseResult.InvokeAsync();
Assert.Equal(0, exitCode);
Assert.Equal(2, fakeClient.Queries.Count);
using var outputJson = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath));
Assert.Equal(2, outputJson.RootElement.GetProperty("count").GetInt32());
Assert.Contains("What changed in stage?", outputJson.RootElement.GetRawText());
}
[Fact]
public async Task ExportCommand_UsesConversationEndpointsAndWritesJson()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-export-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var outputPath = Path.Combine(tempDir, "conversation-export.json");
var fakeClient = new FakeChatClient();
var services = new ServiceCollection()
.AddSingleton<IChatClient>(fakeClient)
.BuildServiceProvider();
var command = AdviseChatCommandGroup.BuildExportCommand(
services,
new StellaOpsCliOptions(),
new Option<bool>("--verbose"),
CancellationToken.None);
var parseResult = command.Parse($"--tenant tenant-001 --user user-001 --format json --output \"{outputPath}\"");
var exitCode = await parseResult.InvokeAsync();
Assert.Equal(0, exitCode);
Assert.Equal(1, fakeClient.ListCalls);
Assert.Equal(1, fakeClient.GetCalls);
var json = await File.ReadAllTextAsync(outputPath);
Assert.Contains("\"conversationCount\": 1", json);
Assert.Contains("\"conversationId\": \"conv-001\"", json);
}
private static ChatQueryResponse CreateSampleQueryResponse()
{
return new ChatQueryResponse
@@ -401,4 +524,136 @@ public sealed class AdviseChatCommandTests
}
};
}
private static ChatConversationExport CreateSampleConversationExport()
{
return new ChatConversationExport
{
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
TenantId = "tenant-001",
UserId = "user-001",
ConversationCount = 1,
Conversations =
[
new ChatConversationResponse
{
ConversationId = "conv-001",
TenantId = "tenant-001",
UserId = "user-001",
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
Turns =
[
new ChatConversationTurn
{
TurnId = "turn-1",
Role = "user",
Content = "What changed?",
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
},
new ChatConversationTurn
{
TurnId = "turn-2",
Role = "assistant",
Content = "Two vulnerabilities were remediated.",
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
}
]
}
]
};
}
private sealed class FakeChatClient : IChatClient
{
public List<string> Queries { get; } = [];
public int ListCalls { get; private set; }
public int GetCalls { get; private set; }
public Task<ChatQueryResponse> QueryAsync(
ChatQueryRequest request,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
Queries.Add(request.Query);
return Task.FromResult(new ChatQueryResponse
{
ResponseId = $"resp-{Queries.Count}",
Intent = "test",
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
Summary = $"Handled {request.Query}",
Confidence = new ChatConfidence
{
Overall = 1,
EvidenceQuality = 1,
ModelCertainty = 1
}
});
}
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
{
ListCalls++;
return Task.FromResult(new ChatConversationListResponse
{
TotalCount = 1,
Conversations =
[
new ChatConversationSummary
{
ConversationId = "conv-001",
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
TurnCount = 2,
Preview = "What changed?"
}
]
});
}
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
{
GetCalls++;
return Task.FromResult(new ChatConversationResponse
{
ConversationId = conversationId,
TenantId = tenantId ?? "tenant-001",
UserId = userId ?? "user-001",
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
Turns =
[
new ChatConversationTurn
{
TurnId = "turn-1",
Role = "user",
Content = "What changed?",
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
},
new ChatConversationTurn
{
TurnId = "turn-2",
Role = "assistant",
Content = "Two updates were deployed.",
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
}
]
});
}
}
}

View File

@@ -85,6 +85,17 @@ public sealed class CommandFactoryTests
Assert.Contains(sbom.Subcommands, command => string.Equals(command.Name, "upload", StringComparison.Ordinal));
}
[Fact]
public void Create_ExposesAdviseExportCommand()
{
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
var services = new ServiceCollection().BuildServiceProvider();
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
var advise = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "advise", StringComparison.Ordinal));
Assert.Contains(advise.Subcommands, command => string.Equals(command.Name, "export", StringComparison.Ordinal));
}
[Fact]
public void Create_ExposesTimestampCommands()
{

View File

@@ -204,6 +204,51 @@ public class CompareCommandTests
Assert.NotNull(backendUrlOption);
}
[Fact]
public void DiffCommand_HasVerificationReportOption()
{
// Arrange
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
// Act
var option = diffCommand.Options.FirstOrDefault(o =>
o.Name == "--verification-report" || o.Aliases.Contains("--verification-report"));
// Assert
Assert.NotNull(option);
}
[Fact]
public void DiffCommand_HasReverifyBundleOption()
{
// Arrange
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
// Act
var option = diffCommand.Options.FirstOrDefault(o =>
o.Name == "--reverify-bundle" || o.Aliases.Contains("--reverify-bundle"));
// Assert
Assert.NotNull(option);
}
[Fact]
public void DiffCommand_HasDeterminismManifestOption()
{
// Arrange
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
// Act
var option = diffCommand.Options.FirstOrDefault(o =>
o.Name == "--determinism-manifest" || o.Aliases.Contains("--determinism-manifest"));
// Assert
Assert.NotNull(option);
}
#endregion
#region Parse Tests
@@ -307,7 +352,7 @@ public class CompareCommandTests
}
[Fact]
public void CompareDiff_FailsWithoutBase()
public void CompareDiff_ParsesWithoutBase()
{
// Arrange
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
@@ -317,7 +362,22 @@ public class CompareCommandTests
var result = root.Parse("compare diff -t sha256:def456");
// Assert
Assert.NotEmpty(result.Errors);
Assert.Empty(result.Errors);
}
[Fact]
public void CompareDiff_ParsesWithVerificationOverlayOptions()
{
// Arrange
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
var root = new RootCommand { command };
// Act
var result = root.Parse(
"compare diff -b sha256:abc123 -t sha256:def456 --verification-report verify.json --reverify-bundle ./bundle --determinism-manifest determinism.json");
// Assert
Assert.Empty(result.Errors);
}
[Fact]

View File

@@ -0,0 +1,162 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cli.Commands.Compare;
namespace StellaOps.Cli.Tests.Commands;
public sealed class CompareVerificationOverlayBuilderTests : IDisposable
{
private readonly string _tempRoot;
public CompareVerificationOverlayBuilderTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
[Fact]
public async Task BuildAsync_ParsesVerificationReportChecks()
{
// Arrange
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
await File.WriteAllTextAsync(
reportPath,
"""
{
"overallStatus": "PASSED_WITH_WARNINGS",
"checks": [
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "warning" }
]
}
""");
// Act
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
reportPath,
reverifyBundlePath: null,
determinismManifestPath: null,
CancellationToken.None);
// Assert
Assert.NotNull(overlay);
Assert.Equal("verification-report", overlay.Source);
Assert.Equal("PASSED_WITH_WARNINGS", overlay.OverallStatus);
Assert.Single(overlay.Artifacts);
var artifact = Assert.Single(overlay.Artifacts);
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
Assert.Equal("pass", artifact.HashStatus);
Assert.Equal("warning", artifact.SignatureStatus);
}
[Fact]
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
{
// Arrange
var bundleDir = Path.Combine(_tempRoot, "bundle");
var inputsDir = Path.Combine(bundleDir, "inputs");
Directory.CreateDirectory(inputsDir);
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
await File.WriteAllTextAsync(
Path.Combine(bundleDir, "manifest.json"),
$$"""
{
"bundle": {
"artifacts": [
{
"path": "inputs/sbom.cdx.json",
"digest": "sha256:{{digest}}"
}
]
}
}
""");
await File.WriteAllTextAsync(
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
"""
{
"payloadType": "application/vnd.in-toto+json",
"signatures": [
{ "keyid": "test-key", "sig": "dGVzdA==" }
]
}
""");
// Act
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath: null,
reverifyBundlePath: bundleDir,
determinismManifestPath: null,
CancellationToken.None);
// Assert
Assert.NotNull(overlay);
Assert.True(overlay.Reverified);
Assert.Equal("reverify-bundle", overlay.Source);
Assert.Single(overlay.Artifacts);
var artifact = Assert.Single(overlay.Artifacts);
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
Assert.Equal("pass", artifact.HashStatus);
Assert.Equal("pass", artifact.SignatureStatus);
Assert.Equal("PASSED", overlay.OverallStatus);
}
[Fact]
public async Task BuildAsync_AttachesDeterminismManifestSummary()
{
// Arrange
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
await File.WriteAllTextAsync(
manifestPath,
"""
{
"overall_score": 0.973,
"thresholds": {
"overall_min": 0.950
},
"images": [
{ "digest": "sha256:aaa" },
{ "digest": "sha256:bbb" }
]
}
""");
// Act
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath: null,
reverifyBundlePath: null,
determinismManifestPath: manifestPath,
CancellationToken.None);
// Assert
Assert.NotNull(overlay);
Assert.NotNull(overlay.Determinism);
Assert.Equal("determinism-manifest", overlay.Source);
Assert.Equal(0.973, overlay.Determinism.OverallScore);
Assert.Equal(0.950, overlay.Determinism.Threshold);
Assert.Equal("pass", overlay.Determinism.Status);
Assert.Equal(2, overlay.Determinism.ImageCount);
Assert.Equal("PASSED", overlay.OverallStatus);
}
private static string ComputeSha256Hex(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,399 @@
// -----------------------------------------------------------------------------
// EvidenceReferrerCommandTests.cs
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
// Description: Unit tests for push-referrer and list-referrers CLI commands.
// -----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", "Unit")]
public sealed class EvidenceReferrerCommandTests
{
// ── ParseImageReference ────────────────────────────────────────────
[Fact]
public void ParseImageReference_WithDigest_ParsesCorrectly()
{
var result = EvidenceReferrerCommands.ParseImageReference(
"registry.example.com/repo/image@sha256:abcdef1234567890");
result.Should().NotBeNull();
result!.Registry.Should().Be("registry.example.com");
result.Repository.Should().Be("repo/image");
result.Digest.Should().Be("sha256:abcdef1234567890");
result.Tag.Should().BeNull();
}
[Fact]
public void ParseImageReference_NoSlash_ReturnsNull()
{
var result = EvidenceReferrerCommands.ParseImageReference("noslash");
result.Should().BeNull();
}
[Fact]
public void ParseImageReference_WithTag_ParsesCorrectly()
{
var result = EvidenceReferrerCommands.ParseImageReference(
"registry.example.com/repo:latest");
result.Should().NotBeNull();
result!.Registry.Should().Be("registry.example.com");
result.Repository.Should().Be("repo");
result.Tag.Should().Be("latest");
}
// ── ParseAnnotations ───────────────────────────────────────────────
[Fact]
public void ParseAnnotations_Null_ReturnsEmpty()
{
var result = EvidenceReferrerCommands.ParseAnnotations(null);
result.Should().BeEmpty();
}
[Fact]
public void ParseAnnotations_ValidPairs_ParsesAll()
{
var result = EvidenceReferrerCommands.ParseAnnotations(
["key1=value1", "key2=value2"]);
result.Should().HaveCount(2);
result["key1"].Should().Be("value1");
result["key2"].Should().Be("value2");
}
[Fact]
public void ParseAnnotations_NoEquals_Skipped()
{
var result = EvidenceReferrerCommands.ParseAnnotations(
["valid=yes", "noequalssign"]);
result.Should().HaveCount(1);
result["valid"].Should().Be("yes");
}
[Fact]
public void ParseAnnotations_ValueWithEquals_PreservesFullValue()
{
var result = EvidenceReferrerCommands.ParseAnnotations(
["key=value=with=equals"]);
result.Should().HaveCount(1);
result["key"].Should().Be("value=with=equals");
}
// ── BuildReferrerManifest ──────────────────────────────────────────
[Fact]
public void BuildReferrerManifest_ProducesValidOciManifest()
{
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
"application/vnd.stellaops.verdict.attestation.v1+json",
"sha256:deadbeef",
1024,
"sha256:cafebabe",
new Dictionary<string, string> { ["key"] = "value" });
manifest.SchemaVersion.Should().Be(2);
manifest.MediaType.Should().Be("application/vnd.oci.image.manifest.v2+json");
manifest.ArtifactType.Should().Be("application/vnd.stellaops.verdict.attestation.v1+json");
manifest.Layers.Should().HaveCount(1);
manifest.Layers[0].Digest.Should().Be("sha256:deadbeef");
manifest.Layers[0].Size.Should().Be(1024);
manifest.Subject.Should().NotBeNull();
manifest.Subject!.Digest.Should().Be("sha256:cafebabe");
manifest.Config.Should().NotBeNull();
manifest.Config!.MediaType.Should().Be("application/vnd.oci.empty.v1+json");
manifest.Annotations.Should().ContainKey("key");
}
[Fact]
public void BuildReferrerManifest_NoAnnotations_NullAnnotations()
{
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
"test/type", "sha256:abc", 100, "sha256:def",
new Dictionary<string, string>());
manifest.Annotations.Should().BeNull();
}
[Fact]
public void BuildReferrerManifest_SerializesToValidJson()
{
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
OciMediaTypes.SbomAttestation, "sha256:1234", 2048, "sha256:5678",
new Dictionary<string, string>());
var json = JsonSerializer.Serialize(manifest);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("schemaVersion").GetInt32().Should().Be(2);
doc.RootElement.GetProperty("artifactType").GetString()
.Should().Be(OciMediaTypes.SbomAttestation);
doc.RootElement.GetProperty("layers").GetArrayLength().Should().Be(1);
}
// ── HandleOfflinePush ──────────────────────────────────────────────
[Fact]
public void HandleOfflinePush_ReturnsZero()
{
var exitCode = EvidenceReferrerCommands.HandleOfflinePush(
"registry/repo@sha256:abc",
OciMediaTypes.VerdictAttestation,
"/tmp/artifact.json",
"sha256:deadbeef",
1024,
new Dictionary<string, string>());
exitCode.Should().Be(0);
}
// ── HandleOfflineList ──────────────────────────────────────────────
[Fact]
public void HandleOfflineList_TableFormat_ReturnsZero()
{
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
"registry/repo@sha256:abc", null, null, "table");
exitCode.Should().Be(0);
}
[Fact]
public void HandleOfflineList_JsonFormat_ReturnsZero()
{
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
"registry/repo@sha256:abc", null, null, "json");
exitCode.Should().Be(0);
}
[Fact]
public void HandleOfflineList_FilterByArtifactType_FiltersResults()
{
// Simulate by checking the offline handler doesn't crash with filter
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
"registry/repo@sha256:abc",
null,
OciMediaTypes.VerdictAttestation,
"table");
exitCode.Should().Be(0);
}
// ── ExecutePushReferrerAsync ────────────────────────────────────────
[Fact]
public async Task ExecutePushReferrer_FileNotFound_ReturnsError()
{
var services = new ServiceCollection().BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
services,
"registry.example.com/repo@sha256:abc",
OciMediaTypes.VerdictAttestation,
"/nonexistent/file.json",
null,
offline: false,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(1);
}
[Fact]
public async Task ExecutePushReferrer_OfflineMode_ReturnsSuccess()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var filePath = Path.Combine(tempDir, "evidence.json");
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
var services = new ServiceCollection().BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
services,
"registry.example.com/repo@sha256:abc",
OciMediaTypes.VerdictAttestation,
filePath,
["org.opencontainers.image.created=2026-01-01"],
offline: true,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(0);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task ExecutePushReferrer_NoOciClient_ReturnsError()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var filePath = Path.Combine(tempDir, "evidence.json");
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
var services = new ServiceCollection().BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
services,
"registry.example.com/repo@sha256:abc",
OciMediaTypes.VerdictAttestation,
filePath,
null,
offline: false,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(1); // No IOciRegistryClient registered
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task ExecutePushReferrer_InvalidImageRef_ReturnsError()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var filePath = Path.Combine(tempDir, "evidence.json");
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
// Register a mock OCI client so we get past the null check
var services = new ServiceCollection()
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
.BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
services,
"noslash", // invalid — no registry/repo split
OciMediaTypes.VerdictAttestation,
filePath,
null,
offline: false,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(1);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ── ExecuteListReferrersAsync ───────────────────────────────────────
[Fact]
public async Task ExecuteListReferrers_OfflineMode_ReturnsSuccess()
{
var services = new ServiceCollection().BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
services,
"registry.example.com/repo@sha256:abc",
null, null, "table",
offline: true,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(0);
}
[Fact]
public async Task ExecuteListReferrers_NoOciClient_ReturnsError()
{
var services = new ServiceCollection().BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
services,
"registry.example.com/repo@sha256:abc",
null, null, "table",
offline: false,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(1);
}
[Fact]
public async Task ExecuteListReferrers_InvalidImageRef_ReturnsError()
{
var services = new ServiceCollection()
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
.BuildServiceProvider();
var logger = NullLogger.Instance;
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
services,
"noslash",
null, null, "table",
offline: false,
verbose: false,
logger,
CancellationToken.None);
exitCode.Should().Be(1);
}
// ── Fake OCI client for testing ────────────────────────────────────
private sealed class FakeOciClient : IOciRegistryClient
{
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
public Task<OciReferrersResponse> ListReferrersAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
=> Task.FromResult(new OciReferrersResponse());
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(string registry, string repository, string digest, string? artifactType = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<OciReferrerDescriptor>>([]);
public Task<OciManifest> GetManifestAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
=> Task.FromResult(new OciManifest());
public Task<byte[]> GetBlobAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
=> Task.FromResult(Array.Empty<byte>());
}
}

View File

@@ -237,6 +237,17 @@ public class Sprint3500_0004_0001_CommandTests
Assert.NotNull(resolveCommand);
}
[Fact]
public void UnknownsCommand_HasExportSubcommand()
{
// Act
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
var exportCommand = command.Subcommands.FirstOrDefault(c => c.Name == "export");
// Assert
Assert.NotNull(exportCommand);
}
[Fact]
public void UnknownsList_ParsesWithBandOption()
{
@@ -279,6 +290,34 @@ public class Sprint3500_0004_0001_CommandTests
Assert.NotEmpty(result.Errors);
}
[Fact]
public void UnknownsExport_ParsesSchemaVersionOption()
{
// Arrange
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
var root = new RootCommand { command };
// Act
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v2");
// Assert
Assert.Empty(result.Errors);
}
[Fact]
public void UnknownsExport_InvalidFormat_ReturnsParseError()
{
// Arrange
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
var root = new RootCommand { command };
// Act
var result = root.Parse("unknowns export --format xml");
// Assert
Assert.NotEmpty(result.Errors);
}
#endregion
#region ScanGraphCommandGroup Tests

View File

@@ -5,6 +5,7 @@
using System.Net;
using System.Net.Http.Json;
using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -259,6 +260,135 @@ public class UnknownsGreyQueueCommandTests
return value;
}
[Fact]
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndDeterministicMetadata()
{
// Arrange
SetupPolicyUnknownsResponse("""
{
"items": [
{
"id": "11111111-1111-1111-1111-111111111111",
"packageId": "pkg:npm/a",
"packageVersion": "1.0.0",
"band": "warm",
"score": 65.5,
"reasonCode": "Reachability",
"reasonCodeShort": "U-RCH",
"firstSeenAt": "2026-01-10T12:00:00Z",
"lastEvaluatedAt": "2026-01-15T08:00:00Z"
},
{
"id": "22222222-2222-2222-2222-222222222222",
"packageId": "pkg:npm/b",
"packageVersion": "2.0.0",
"band": "hot",
"score": 90.0,
"reasonCode": "PolicyConflict",
"reasonCodeShort": "U-POL",
"firstSeenAt": "2026-01-09T12:00:00Z",
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
}
],
"totalCount": 2
}
""");
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
var root = new RootCommand { command };
using var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
// Assert
Assert.Equal(0, exitCode);
using var doc = JsonDocument.Parse(writer.ToString());
var rootElement = doc.RootElement;
Assert.Equal("unknowns.export.v2", rootElement.GetProperty("schemaVersion").GetString());
Assert.Equal(2, rootElement.GetProperty("itemCount").GetInt32());
var exportedAt = rootElement.GetProperty("exportedAt").GetDateTimeOffset();
Assert.Equal(DateTimeOffset.Parse("2026-01-15T09:30:00+00:00"), exportedAt);
var items = rootElement.GetProperty("items");
Assert.Equal(2, items.GetArrayLength());
Assert.Equal("hot", items[0].GetProperty("band").GetString());
Assert.Equal("warm", items[1].GetProperty("band").GetString());
}
[Fact]
public async Task UnknownsExport_Csv_IncludesSchemaHeaderLine()
{
// Arrange
SetupPolicyUnknownsResponse("""
{
"items": [
{
"id": "33333333-3333-3333-3333-333333333333",
"packageId": "pkg:npm/c",
"packageVersion": "3.0.0",
"band": "cold",
"score": 10.0,
"reasonCode": "None",
"reasonCodeShort": "U-NA",
"firstSeenAt": "2026-01-01T00:00:00Z",
"lastEvaluatedAt": "2026-01-02T00:00:00Z"
}
],
"totalCount": 1
}
""");
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
var root = new RootCommand { command };
using var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await root.Parse("unknowns export --format csv --schema-version unknowns.export.v9").InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
// Assert
Assert.Equal(0, exitCode);
var output = writer.ToString();
Assert.Contains("# schema_version=unknowns.export.v9;", output);
Assert.Contains("item_count=1", output);
Assert.Contains("id,package_id,package_version,band,score", output);
}
private void SetupPolicyUnknownsResponse(string json)
{
_httpHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(request =>
request.Method == HttpMethod.Get &&
request.RequestUri != null &&
request.RequestUri.ToString().Contains("/api/v1/policy/unknowns", StringComparison.Ordinal)),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
});
}
// Test DTOs matching the CLI internal types
private sealed record TestUnknownsSummaryResponse
{

View File

@@ -0,0 +1,193 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using FluentAssertions;
using StellaOps.PolicyDsl;
using Xunit;
namespace StellaOps.Cli.Plugins.Policy.Tests;
public class PolicyCliIntegrationTests
{
private const string ValidPolicySource = @"
policy ""Test Policy"" syntax ""stella-dsl@1"" {
metadata {
author: ""test@example.com""
version: ""1.0.0""
}
settings {
default_action: ""allow""
}
rule allow_all (10) {
when true
then {
allow()
}
}
}
";
private const string InvalidPolicySource = @"
policy ""Invalid Policy""
// Missing syntax declaration
{
metadata {
author: ""test@example.com""
}
}
";
[Fact]
public void PolicyParser_ParsesValidPolicy_ReturnsSuccess()
{
// Act
var result = PolicyParser.Parse(ValidPolicySource);
// Assert
result.Document.Should().NotBeNull();
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
.Should().BeEmpty();
}
[Fact]
public void PolicyParser_ParsesInvalidPolicy_ReturnsDiagnostics()
{
// Act
var result = PolicyParser.Parse(InvalidPolicySource);
// Assert
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
.Should().NotBeEmpty();
}
[Fact]
public void PolicyCompiler_CompilesValidPolicy_ReturnsChecksum()
{
// Arrange
var compiler = new PolicyCompiler();
// Act
var result = compiler.Compile(ValidPolicySource);
// Assert
result.Success.Should().BeTrue();
result.Checksum.Should().NotBeNullOrEmpty();
result.Checksum.Should().HaveLength(64); // SHA-256 hex
result.Document.Should().NotBeNull();
}
[Fact]
public void PolicyCompiler_CompilesInvalidPolicy_ReturnsFailure()
{
// Arrange
var compiler = new PolicyCompiler();
// Act
var result = compiler.Compile(InvalidPolicySource);
// Assert
result.Success.Should().BeFalse();
}
[Fact]
public void PolicyCompiler_IsDeterministic_SameInputProducesSameChecksum()
{
// Arrange
var compiler = new PolicyCompiler();
// Act
var result1 = compiler.Compile(ValidPolicySource);
var result2 = compiler.Compile(ValidPolicySource);
// Assert
result1.Success.Should().BeTrue();
result2.Success.Should().BeTrue();
result1.Checksum.Should().Be(result2.Checksum);
}
[Fact]
public void PolicyEngineFactory_CreatesEngineFromSource()
{
// Arrange
var factory = new PolicyEngineFactory();
// Act
var result = factory.CreateFromSource(ValidPolicySource);
// Assert
result.Engine.Should().NotBeNull();
result.Engine!.Name.Should().Be("Test Policy");
result.Engine.Syntax.Should().Be("stella-dsl@1");
}
[Fact]
public void PolicyEngine_EvaluatesAgainstEmptyContext()
{
// Arrange
var factory = new PolicyEngineFactory();
var engineResult = factory.CreateFromSource(ValidPolicySource);
var engine = engineResult.Engine!;
var context = new SignalContext();
// Act
var result = engine.Evaluate(context);
// Assert
result.PolicyName.Should().Be("Test Policy");
result.PolicyChecksum.Should().NotBeNullOrEmpty();
}
[Fact]
public void PolicyEngine_EvaluatesAgainstSignalContext()
{
// Arrange
var factory = new PolicyEngineFactory();
var engineResult = factory.CreateFromSource(ValidPolicySource);
var engine = engineResult.Engine!;
var context = new SignalContext()
.SetSignal("cvss.score", 8.5)
.SetSignal("cve.reachable", true);
// Act
var result = engine.Evaluate(context);
// Assert
result.Should().NotBeNull();
result.MatchedRules.Should().NotBeNull();
}
[Fact]
public void SignalContext_StoresAndRetrievesValues()
{
// Arrange
var context = new SignalContext();
// Act
context.SetSignal("test.string", "hello");
context.SetSignal("test.number", 42);
context.SetSignal("test.boolean", true);
// Assert
context.HasSignal("test.string").Should().BeTrue();
context.GetSignal<string>("test.string").Should().Be("hello");
context.GetSignal<int>("test.number").Should().Be(42);
context.GetSignal<bool>("test.boolean").Should().BeTrue();
context.HasSignal("nonexistent").Should().BeFalse();
}
[Fact]
public void PolicyIrSerializer_SerializesToDeterministicBytes()
{
// Arrange
var compiler = new PolicyCompiler();
var result = compiler.Compile(ValidPolicySource);
// Act
var bytes1 = PolicyIrSerializer.Serialize(result.Document!);
var bytes2 = PolicyIrSerializer.Serialize(result.Document!);
// Assert
bytes1.SequenceEqual(bytes2).Should().BeTrue();
}
}

View File

@@ -0,0 +1,372 @@
// -----------------------------------------------------------------------------
// BaselineResolverTests.cs
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
// Description: Unit tests for baseline resolution service.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Tests.Services;
public sealed class BaselineResolverTests
{
private readonly IForensicSnapshotClient _forensicClient;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<BaselineResolver> _logger;
private readonly FakeTimeProvider _timeProvider;
private readonly BaselineResolver _resolver;
public BaselineResolverTests()
{
_forensicClient = Substitute.For<IForensicSnapshotClient>();
_options = new StellaOpsCliOptions { DefaultTenant = "test-tenant" };
_logger = Substitute.For<ILogger<BaselineResolver>>();
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_resolver = new BaselineResolver(
_forensicClient,
_options,
_logger,
_timeProvider);
}
[Fact]
public async Task ResolveAsync_ExplicitStrategy_ReturnsProvidedDigest()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp@sha256:abc123",
Strategy = BaselineStrategy.Explicit,
ExplicitDigest = "sha256:cafebabe"
};
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.True(result.Success);
Assert.Equal("sha256:cafebabe", result.Digest);
Assert.Equal(BaselineStrategy.Explicit, result.Strategy);
Assert.Null(result.Error);
}
[Fact]
public async Task ResolveAsync_ExplicitStrategy_WithoutDigest_ReturnsFalse()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp@sha256:abc123",
Strategy = BaselineStrategy.Explicit,
ExplicitDigest = null
};
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.False(result.Success);
Assert.Null(result.Digest);
Assert.Contains("requires a digest", result.Error);
}
[Fact]
public async Task ResolveAsync_LastGreen_ReturnsLatestPassingSnapshot()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.LastGreen
};
var snapshot = new ForensicSnapshotDocument
{
SnapshotId = "snap-001",
CaseId = "case-001",
Tenant = "test-tenant",
Status = ForensicSnapshotStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
Tags = ["verdict:pass", "artifact:pkg%3Aoci%2Fmyapp"],
Manifest = new ForensicSnapshotManifest
{
ManifestId = "manifest-001",
Digest = "sha256:lastgreen123"
}
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse
{
Snapshots = [snapshot],
Total = 1
});
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.True(result.Success);
Assert.Equal("sha256:lastgreen123", result.Digest);
Assert.Equal(BaselineStrategy.LastGreen, result.Strategy);
}
[Fact]
public async Task ResolveAsync_LastGreen_NoPassingSnapshots_ReturnsFalse()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.LastGreen
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse
{
Snapshots = [],
Total = 0
});
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.False(result.Success);
Assert.Null(result.Digest);
Assert.Contains("No passing snapshot found", result.Error);
Assert.NotNull(result.Suggestion);
}
[Fact]
public async Task ResolveAsync_PreviousRelease_ReturnsOlderRelease()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.PreviousRelease,
CurrentVersion = "v2.0.0"
};
var v2Snapshot = new ForensicSnapshotDocument
{
SnapshotId = "snap-002",
CaseId = "case-001",
Tenant = "test-tenant",
Status = ForensicSnapshotStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow,
Tags = ["release:true", "version:v2.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
Manifest = new ForensicSnapshotManifest
{
ManifestId = "manifest-002",
Digest = "sha256:v2digest"
}
};
var v1Snapshot = new ForensicSnapshotDocument
{
SnapshotId = "snap-001",
CaseId = "case-001",
Tenant = "test-tenant",
Status = ForensicSnapshotStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
Tags = ["release:true", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
Manifest = new ForensicSnapshotManifest
{
ManifestId = "manifest-001",
Digest = "sha256:v1digest"
}
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse
{
Snapshots = [v2Snapshot, v1Snapshot],
Total = 2
});
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.True(result.Success);
Assert.Equal("sha256:v1digest", result.Digest);
Assert.Equal(BaselineStrategy.PreviousRelease, result.Strategy);
}
[Fact]
public async Task ResolveAsync_PreviousRelease_NoReleases_ReturnsFalse()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.PreviousRelease
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse
{
Snapshots = [],
Total = 0
});
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.False(result.Success);
Assert.Null(result.Digest);
Assert.Contains("No release snapshots found", result.Error);
}
[Fact]
public async Task ResolveAsync_ClientException_ReturnsFalseWithError()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.LastGreen
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.False(result.Success);
Assert.Null(result.Digest);
Assert.Contains("Connection refused", result.Error);
}
[Fact]
public async Task GetSuggestionsAsync_ReturnsSuggestionsFromMultipleSources()
{
// Arrange
var passingSnapshot = new ForensicSnapshotDocument
{
SnapshotId = "snap-pass-001",
CaseId = "case-001",
Tenant = "test-tenant",
Status = ForensicSnapshotStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
Tags = ["verdict:pass", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
Manifest = new ForensicSnapshotManifest
{
ManifestId = "manifest-001",
Digest = "sha256:passing123"
}
};
var releaseSnapshot = new ForensicSnapshotDocument
{
SnapshotId = "snap-rel-001",
CaseId = "case-001",
Tenant = "test-tenant",
Status = ForensicSnapshotStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-2),
Tags = ["release:true", "version:v0.9.0", "artifact:pkg%3Aoci%2Fmyapp"],
Manifest = new ForensicSnapshotManifest
{
ManifestId = "manifest-002",
Digest = "sha256:release123"
}
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(
new ForensicSnapshotListResponse { Snapshots = [passingSnapshot], Total = 1 },
new ForensicSnapshotListResponse { Snapshots = [releaseSnapshot], Total = 1 });
// Act
var suggestions = await _resolver.GetSuggestionsAsync("pkg:oci/myapp");
// Assert
Assert.Equal(2, suggestions.Count);
Assert.Contains(suggestions, s => s.Digest == "sha256:passing123" && s.RecommendedStrategy == BaselineStrategy.LastGreen);
Assert.Contains(suggestions, s => s.Digest == "sha256:release123" && s.RecommendedStrategy == BaselineStrategy.PreviousRelease);
}
[Fact]
public async Task ResolveAsync_UsesDefaultTenant_WhenNotProvided()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.LastGreen,
TenantId = null
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
// Act
await _resolver.ResolveAsync(request);
// Assert
await _forensicClient.Received(1).ListSnapshotsAsync(
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "test-tenant"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ResolveAsync_UsesTenantFromRequest_WhenProvided()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = BaselineStrategy.LastGreen,
TenantId = "custom-tenant"
};
_forensicClient
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
// Act
await _resolver.ResolveAsync(request);
// Assert
await _forensicClient.Received(1).ListSnapshotsAsync(
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "custom-tenant"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ResolveAsync_UnknownStrategy_ReturnsFalse()
{
// Arrange
var request = new BaselineResolutionRequest
{
ArtifactId = "pkg:oci/myapp",
Strategy = (BaselineStrategy)99 // Invalid strategy
};
// Act
var result = await _resolver.ResolveAsync(request);
// Assert
Assert.False(result.Success);
Assert.Contains("Unknown baseline strategy", result.Error);
}
}

View File

@@ -30,6 +30,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Spectre.Console.Testing" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="coverlet.collector" >

View File

@@ -1,4 +1,4 @@
# CLI Tests Task Board
# CLI Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
@@ -34,3 +34,14 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| ATT-005 | DONE | SPRINT_20260119_010 - Timestamp CLI workflow tests added. |
| TASK-032-004 | DONE | SPRINT_20260120_032 - Analytics CLI tests and fixtures added. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08).
| SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08).
| SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08).

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Cli.Extensions
{
// Isolated test project stub namespace for UnknownsCommandGroup compile.
internal static class CompatStubs
{
}
}
namespace StellaOps.Policy.Unknowns.Models
{
// Isolated test project stubs to satisfy UnknownsCommandGroup compile.
public sealed record UnknownPlaceholder;
}
namespace System.CommandLine
{
// Compatibility shims for the System.CommandLine API shape expected by UnknownsCommandGroup.
public static class OptionCompatExtensions
{
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
{
return option;
}
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
{
return option;
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<RootNamespace>StellaOps.Cli.UnknownsExport.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\StellaOps.Cli\Commands\UnknownsCommandGroup.cs" Link="Commands\UnknownsCommandGroup.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,141 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Commands;
namespace StellaOps.Cli.UnknownsExport.Tests;
public sealed class UnknownsExportIsolationTests
{
[Fact]
public void UnknownsExport_ParsesSchemaVersionAndFormat()
{
var services = BuildServices([]);
var command = UnknownsCommandGroup.BuildUnknownsCommand(
services,
new Option<bool>("--verbose"),
CancellationToken.None);
var root = new RootCommand { command };
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v9");
Assert.Empty(result.Errors);
}
[Fact]
public void UnknownsExport_DefaultFormat_Parses()
{
var services = BuildServices([]);
var command = UnknownsCommandGroup.BuildUnknownsCommand(
services,
new Option<bool>("--verbose"),
CancellationToken.None);
var root = new RootCommand { command };
var result = root.Parse("unknowns export");
Assert.Empty(result.Errors);
}
[Fact]
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndMetadata()
{
var payload = """
{
"items": [
{
"id": "11111111-1111-1111-1111-111111111111",
"packageId": "pkg:npm/a@1.0.0",
"packageVersion": "1.0.0",
"band": "hot",
"score": 0.99,
"reasonCode": "r1",
"reasonCodeShort": "r1",
"firstSeenAt": "2026-01-01T00:00:00Z",
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
},
{
"id": "22222222-2222-2222-2222-222222222222",
"packageId": "pkg:npm/b@1.0.0",
"packageVersion": "1.0.0",
"band": "warm",
"score": 0.55,
"reasonCode": "r2",
"reasonCodeShort": "r2",
"firstSeenAt": "2026-01-01T00:00:00Z",
"lastEvaluatedAt": "2026-01-14T09:30:00Z"
}
],
"totalCount": 2
}
""";
var services = BuildServices([("/api/v1/policy/unknowns?limit=10000", payload)]);
var command = UnknownsCommandGroup.BuildUnknownsCommand(
services,
new Option<bool>("--verbose"),
CancellationToken.None);
var root = new RootCommand { command };
var writer = new StringWriter();
var originalOut = Console.Out;
int exitCode;
try
{
Console.SetOut(writer);
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
}
finally
{
Console.SetOut(originalOut);
}
var output = writer.ToString();
Assert.True(exitCode == 0, $"ExitCode={exitCode}; Output={output}");
Assert.Contains("\"schemaVersion\": \"unknowns.export.v2\"", output, StringComparison.Ordinal);
Assert.Contains("\"itemCount\": 2", output, StringComparison.Ordinal);
Assert.Contains("\"exportedAt\": \"2026-01-15T09:30:00+00:00\"", output, StringComparison.Ordinal);
}
private static ServiceProvider BuildServices(IReadOnlyList<(string Path, string Json)> payloads)
{
var services = new ServiceCollection();
services.AddLogging(static builder => builder.SetMinimumLevel(LogLevel.Warning));
services.AddHttpClient("PolicyApi")
.ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost"))
.ConfigurePrimaryHttpMessageHandler(() => new TestHandler(payloads));
return services.BuildServiceProvider();
}
private sealed class TestHandler : HttpMessageHandler
{
private readonly Dictionary<string, string> _responses;
public TestHandler(IReadOnlyList<(string Path, string Json)> payloads)
{
_responses = payloads.ToDictionary(
static x => x.Path,
static x => x.Json,
StringComparer.OrdinalIgnoreCase);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var key = request.RequestUri?.PathAndQuery ?? string.Empty;
if (_responses.TryGetValue(key, out var json))
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)
{
Content = new StringContent("""{"error":"not found"}""", Encoding.UTF8, "application/json")
});
}
}
}