Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Drift.cs
master 0dc71e760a feat: Add PathViewer and RiskDriftCard components with templates and styles
- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
2025-12-18 18:35:30 +02:00

321 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// CommandHandlers.Drift.cs
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
// Tasks: UI-019, UI-020, UI-021
// Description: Command handlers for reachability drift CLI.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Spectre.Console;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
private static readonly JsonSerializerOptions DriftJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Handler for `drift compare` command.
/// </summary>
internal static async Task HandleDriftCompareAsync(
IServiceProvider services,
string baseId,
string? headId,
string? image,
string? repo,
string output,
string minSeverity,
bool onlyIncreases,
bool verbose,
CancellationToken cancellationToken)
{
// TODO: Replace with actual service call when drift API is available
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Comparing drift: base={baseId}, head={headId ?? "(latest)"}[/]");
}
// Placeholder: In real implementation, call drift service
var driftResult = new DriftResultDto
{
Id = Guid.NewGuid().ToString("N")[..8],
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
BaseGraphId = baseId,
HeadGraphId = headId ?? "latest",
Summary = new DriftSummaryDto
{
TotalSinks = 0,
IncreasedReachability = 0,
DecreasedReachability = 0,
UnchangedReachability = 0,
NewSinks = 0,
RemovedSinks = 0,
RiskTrend = "stable",
NetRiskDelta = 0
},
DriftedSinks = Array.Empty<DriftedSinkDto>()
};
switch (output)
{
case "json":
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
break;
case "sarif":
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
break;
default:
WriteTableOutput(console, driftResult, onlyIncreases, minSeverity);
break;
}
}
/// <summary>
/// Handler for `drift show` command.
/// </summary>
internal static async Task HandleDriftShowAsync(
IServiceProvider services,
string id,
string output,
bool expandPaths,
bool verbose,
CancellationToken cancellationToken)
{
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Showing drift result: {id}[/]");
}
// Placeholder: In real implementation, call drift service
var driftResult = new DriftResultDto
{
Id = id,
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
BaseGraphId = "base",
HeadGraphId = "head",
Summary = new DriftSummaryDto
{
TotalSinks = 0,
IncreasedReachability = 0,
DecreasedReachability = 0,
UnchangedReachability = 0,
NewSinks = 0,
RemovedSinks = 0,
RiskTrend = "stable",
NetRiskDelta = 0
},
DriftedSinks = Array.Empty<DriftedSinkDto>()
};
switch (output)
{
case "json":
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
break;
case "sarif":
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
break;
default:
WriteTableOutput(console, driftResult, false, "info");
break;
}
}
// Task: UI-020 - Table output using Spectre.Console
private static void WriteTableOutput(
IAnsiConsole console,
DriftResultDto result,
bool onlyIncreases,
string minSeverity)
{
// Header panel
var header = new Panel(new Markup($"[bold]Reachability Drift[/] [dim]({result.Id})[/]"))
.Border(BoxBorder.Rounded)
.Padding(1, 0);
console.Write(header);
// Summary table
var summaryTable = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Metric")
.AddColumn("Value");
summaryTable.AddRow("Trend", FormatTrend(result.Summary.RiskTrend));
summaryTable.AddRow("Net Risk Delta", FormatDelta(result.Summary.NetRiskDelta));
summaryTable.AddRow("Increased", result.Summary.IncreasedReachability.ToString());
summaryTable.AddRow("Decreased", result.Summary.DecreasedReachability.ToString());
summaryTable.AddRow("New Sinks", result.Summary.NewSinks.ToString());
summaryTable.AddRow("Removed Sinks", result.Summary.RemovedSinks.ToString());
console.Write(summaryTable);
// Sinks table
if (result.DriftedSinks.Length == 0)
{
console.MarkupLine("[green]No drifted sinks found.[/]");
return;
}
var sinksTable = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Severity")
.AddColumn("Sink")
.AddColumn("CVE")
.AddColumn("Bucket Change")
.AddColumn("Delta");
var severityOrder = new Dictionary<string, int>
{
["critical"] = 0,
["high"] = 1,
["medium"] = 2,
["low"] = 3,
["info"] = 4
};
var minSevOrder = severityOrder.GetValueOrDefault(minSeverity, 2);
foreach (var sink in result.DriftedSinks)
{
var sevOrder = severityOrder.GetValueOrDefault(sink.Severity ?? "info", 4);
if (sevOrder > minSevOrder) continue;
if (onlyIncreases && !sink.IsRiskIncrease) continue;
sinksTable.AddRow(
FormatSeverity(sink.Severity),
sink.SinkSymbol ?? "unknown",
sink.CveId ?? "-",
$"{sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}",
FormatDelta(sink.RiskDelta));
}
console.Write(sinksTable);
}
// Task: UI-021 - JSON output
private static async Task WriteJsonOutputAsync(
IAnsiConsole console,
DriftResultDto result,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(result, DriftJsonOptions);
console.WriteLine(json);
await Task.CompletedTask;
}
// Task: UI-022, UI-023 - SARIF output (placeholder)
private static async Task WriteSarifOutputAsync(
IAnsiConsole console,
DriftResultDto result,
CancellationToken cancellationToken)
{
// TODO: Implement full SARIF 2.1.0 generation in DriftSarifGenerator
var sarif = new
{
version = "2.1.0",
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
runs = new[]
{
new
{
tool = new
{
driver = new
{
name = "StellaOps Drift",
version = "1.0.0",
informationUri = "https://stellaops.io/docs/drift"
}
},
results = result.DriftedSinks.Select(sink => new
{
ruleId = sink.CveId ?? $"drift-{sink.SinkSymbol}",
level = MapSeverityToSarif(sink.Severity),
message = new
{
text = $"Reachability changed: {sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}"
},
locations = Array.Empty<object>()
}).ToArray()
}
}
};
var json = JsonSerializer.Serialize(sarif, DriftJsonOptions);
console.WriteLine(json);
await Task.CompletedTask;
}
private static string FormatTrend(string trend) => trend switch
{
"increasing" => "[red]↑ Increasing[/]",
"decreasing" => "[green]↓ Decreasing[/]",
_ => "[dim]→ Stable[/]"
};
private static string FormatDelta(int delta) => delta switch
{
> 0 => $"[red]+{delta}[/]",
< 0 => $"[green]{delta}[/]",
_ => "[dim]0[/]"
};
private static string FormatSeverity(string? severity) => severity switch
{
"critical" => "[white on red] CRITICAL [/]",
"high" => "[black on darkorange] HIGH [/]",
"medium" => "[black on yellow] MEDIUM [/]",
"low" => "[black on olive] LOW [/]",
_ => "[dim] INFO [/]"
};
private static string MapSeverityToSarif(string? severity) => severity switch
{
"critical" or "high" => "error",
"medium" => "warning",
_ => "note"
};
// DTOs for drift output
private sealed record DriftResultDto
{
public string Id { get; init; } = string.Empty;
public string ComparedAt { get; init; } = string.Empty;
public string BaseGraphId { get; init; } = string.Empty;
public string HeadGraphId { get; init; } = string.Empty;
public DriftSummaryDto Summary { get; init; } = new();
public DriftedSinkDto[] DriftedSinks { get; init; } = Array.Empty<DriftedSinkDto>();
}
private sealed record DriftSummaryDto
{
public int TotalSinks { get; init; }
public int IncreasedReachability { get; init; }
public int DecreasedReachability { get; init; }
public int UnchangedReachability { get; init; }
public int NewSinks { get; init; }
public int RemovedSinks { get; init; }
public string RiskTrend { get; init; } = "stable";
public int NetRiskDelta { get; init; }
}
private sealed record DriftedSinkDto
{
public string? SinkSymbol { get; init; }
public string? CveId { get; init; }
public string? Severity { get; init; }
public string? PreviousBucket { get; init; }
public string CurrentBucket { get; init; } = string.Empty;
public bool IsRiskIncrease { get; init; }
public int RiskDelta { get; init; }
}
}