// -----------------------------------------------------------------------------
// PrReachabilityGate.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-014)
// Description: Evaluates incremental reachability results for PR gate decisions.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Reachability.Cache;
///
/// Configuration options for PR reachability gate evaluation.
///
public sealed class PrReachabilityGateOptions
{
///
/// Section name for configuration binding.
///
public const string SectionName = "Scanner:Reachability:PrGate";
///
/// Whether the PR gate is enabled. Default: true.
///
public bool Enabled { get; set; } = true;
///
/// Whether to block PRs that introduce new reachable vulnerabilities. Default: true.
///
public bool BlockOnNewReachable { get; set; } = true;
///
/// Maximum number of new reachable paths allowed before blocking. Default: 0.
///
public int MaxNewReachablePaths { get; set; } = 0;
///
/// Whether to require a minimum confidence level for blocking decisions. Default: true.
///
public bool RequireMinimumConfidence { get; set; } = true;
///
/// Minimum confidence level (0.0-1.0) for a path to count as blocking. Default: 0.7.
///
public double MinimumConfidenceThreshold { get; set; } = 0.7;
///
/// Whether to add annotations to the PR for state flips. Default: true.
///
public bool AddAnnotations { get; set; } = true;
///
/// Maximum number of annotations to add per PR. Default: 10.
///
public int MaxAnnotations { get; set; } = 10;
///
/// Whether to include mitigated paths in the summary. Default: true.
///
public bool IncludeMitigatedInSummary { get; set; } = true;
}
///
/// Result of PR gate evaluation.
///
public sealed record PrGateResult
{
///
/// Whether the PR passed the gate.
///
public required bool Passed { get; init; }
///
/// Human-readable reason for the decision.
///
public required string Reason { get; init; }
///
/// Detailed decision breakdown.
///
public required PrGateDecision Decision { get; init; }
///
/// Annotations to add to the PR.
///
public IReadOnlyList Annotations { get; init; } = [];
///
/// Summary markdown for PR comment.
///
public string? SummaryMarkdown { get; init; }
}
///
/// Detailed breakdown of PR gate decision.
///
public sealed record PrGateDecision
{
///
/// Number of new reachable vulnerability paths introduced.
///
public int NewReachableCount { get; init; }
///
/// Number of vulnerability paths mitigated (became unreachable).
///
public int MitigatedCount { get; init; }
///
/// Net change in reachable vulnerability paths.
///
public int NetChange { get; init; }
///
/// Whether incremental analysis was used.
///
public bool WasIncremental { get; init; }
///
/// Cache savings ratio (1.0 = 100% cached, 0.0 = full recompute).
///
public double SavingsRatio { get; init; }
///
/// Analysis duration.
///
public TimeSpan Duration { get; init; }
///
/// State flips that caused blocking.
///
public IReadOnlyList BlockingFlips { get; init; } = [];
}
///
/// Annotation to add to a PR.
///
public sealed record PrGateAnnotation
{
///
/// Annotation level (error, warning, notice).
///
public required PrAnnotationLevel Level { get; init; }
///
/// Annotation message.
///
public required string Message { get; init; }
///
/// File path (if applicable).
///
public string? FilePath { get; init; }
///
/// Start line (if applicable).
///
public int? StartLine { get; init; }
///
/// End line (if applicable).
///
public int? EndLine { get; init; }
///
/// Annotation title.
///
public string? Title { get; init; }
}
///
/// PR annotation severity level.
///
public enum PrAnnotationLevel
{
///
/// Notice level (informational).
///
Notice,
///
/// Warning level (non-blocking).
///
Warning,
///
/// Error level (blocking).
///
Error
}
///
/// Evaluates incremental reachability results for PR gate decisions.
///
public interface IPrReachabilityGate
{
///
/// Evaluates an incremental reachability result for PR gating.
///
/// The incremental reachability result.
/// The PR gate evaluation result.
PrGateResult Evaluate(IncrementalReachabilityResult result);
///
/// Evaluates state flips directly for PR gating.
///
/// The state flip result.
/// Whether incremental analysis was used.
/// Cache savings ratio.
/// Analysis duration.
/// The PR gate evaluation result.
PrGateResult EvaluateFlips(
StateFlipResult stateFlips,
bool wasIncremental,
double savingsRatio,
TimeSpan duration);
}
///
/// Default implementation of .
///
public sealed class PrReachabilityGate : IPrReachabilityGate
{
private readonly IOptionsMonitor _options;
private readonly ILogger _logger;
///
/// Creates a new PR reachability gate.
///
public PrReachabilityGate(
IOptionsMonitor options,
ILogger logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
public PrGateResult Evaluate(IncrementalReachabilityResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (result.StateFlips is null)
{
return CreatePassResult(
"No state flip detection performed",
wasIncremental: result.WasIncremental,
savingsRatio: result.SavingsRatio,
duration: result.Duration);
}
return EvaluateFlips(
result.StateFlips,
result.WasIncremental,
result.SavingsRatio,
result.Duration);
}
///
public PrGateResult EvaluateFlips(
StateFlipResult stateFlips,
bool wasIncremental,
double savingsRatio,
TimeSpan duration)
{
ArgumentNullException.ThrowIfNull(stateFlips);
var options = _options.CurrentValue;
// If gate is disabled, always pass
if (!options.Enabled)
{
_logger.LogDebug("PR gate is disabled, passing");
return CreatePassResult(
"PR gate is disabled",
wasIncremental,
savingsRatio,
duration);
}
// No flips = pass
if (!stateFlips.HasFlips)
{
_logger.LogDebug("No reachability changes detected");
return CreatePassResult(
"No reachability changes",
wasIncremental,
savingsRatio,
duration);
}
// Filter blocking flips by confidence if required
var blockingFlips = options.RequireMinimumConfidence
? stateFlips.NewlyReachable
.Where(f => f.Confidence >= options.MinimumConfidenceThreshold)
.ToList()
: stateFlips.NewlyReachable.ToList();
var blockingCount = blockingFlips.Count;
// Check if should block
var shouldBlock = options.BlockOnNewReachable &&
blockingCount > options.MaxNewReachablePaths;
var decision = new PrGateDecision
{
NewReachableCount = stateFlips.NewRiskCount,
MitigatedCount = stateFlips.MitigatedCount,
NetChange = stateFlips.NetChange,
WasIncremental = wasIncremental,
SavingsRatio = savingsRatio,
Duration = duration,
BlockingFlips = blockingFlips
};
if (shouldBlock)
{
_logger.LogWarning(
"PR gate BLOCKED: {Count} new reachable vulnerability paths introduced",
blockingCount);
PrReachabilityGateMetrics.BlockedPrs.Add(1);
return new PrGateResult
{
Passed = false,
Reason = $"{blockingCount} vulnerabilities became reachable",
Decision = decision,
Annotations = BuildAnnotations(blockingFlips, options),
SummaryMarkdown = BuildSummaryMarkdown(decision, options, passed: false)
};
}
_logger.LogInformation(
"PR gate PASSED: {NewCount} new, {MitigatedCount} mitigated (net: {Net})",
stateFlips.NewRiskCount, stateFlips.MitigatedCount, stateFlips.NetChange);
PrReachabilityGateMetrics.PassedPrs.Add(1);
var reason = stateFlips.MitigatedCount > 0
? $"{stateFlips.MitigatedCount} vulnerabilities mitigated"
: "Reachability changes within threshold";
return new PrGateResult
{
Passed = true,
Reason = reason,
Decision = decision,
Annotations = BuildAnnotations(blockingFlips, options),
SummaryMarkdown = BuildSummaryMarkdown(decision, options, passed: true)
};
}
private PrGateResult CreatePassResult(
string reason,
bool wasIncremental,
double savingsRatio,
TimeSpan duration)
{
return new PrGateResult
{
Passed = true,
Reason = reason,
Decision = new PrGateDecision
{
NewReachableCount = 0,
MitigatedCount = 0,
NetChange = 0,
WasIncremental = wasIncremental,
SavingsRatio = savingsRatio,
Duration = duration,
BlockingFlips = []
},
Annotations = [],
SummaryMarkdown = null
};
}
private static IReadOnlyList BuildAnnotations(
IReadOnlyList blockingFlips,
PrReachabilityGateOptions options)
{
if (!options.AddAnnotations || blockingFlips.Count == 0)
return [];
var annotations = new List();
var flipsToAnnotate = blockingFlips.Take(options.MaxAnnotations);
foreach (var flip in flipsToAnnotate)
{
annotations.Add(new PrGateAnnotation
{
Level = PrAnnotationLevel.Error,
Title = "New Reachable Vulnerability Path",
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} -> {flip.SinkMethodKey}",
FilePath = flip.SourceFile,
StartLine = flip.StartLine,
EndLine = flip.EndLine
});
}
return annotations;
}
private static string BuildSummaryMarkdown(
PrGateDecision decision,
PrReachabilityGateOptions options,
bool passed)
{
var sb = new StringBuilder();
sb.AppendLine(passed
? "## ✅ Reachability Gate Passed"
: "## ❌ Reachability Gate Blocked");
sb.AppendLine();
sb.AppendLine("| Metric | Value |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| New reachable paths | {decision.NewReachableCount} |");
if (options.IncludeMitigatedInSummary)
{
sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount} |");
sb.AppendLine($"| Net change | {decision.NetChange:+#;-#;0} |");
}
sb.AppendLine($"| Analysis type | {(decision.WasIncremental ? "Incremental" : "Full")} |");
sb.AppendLine($"| Cache savings | {decision.SavingsRatio:P0} |");
sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds:F0}ms |");
if (!passed && decision.BlockingFlips.Count > 0)
{
sb.AppendLine();
sb.AppendLine("### Blocking Paths");
sb.AppendLine();
foreach (var flip in decision.BlockingFlips.Take(10))
{
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
}
if (decision.BlockingFlips.Count > 10)
{
sb.AppendLine($"- ... and {decision.BlockingFlips.Count - 10} more");
}
}
return sb.ToString();
}
}
///
/// Metrics for PR reachability gate.
///
internal static class PrReachabilityGateMetrics
{
private static readonly string MeterName = "StellaOps.Scanner.Reachability.PrGate";
///
/// Counter for passed PRs.
///
public static readonly System.Diagnostics.Metrics.Counter PassedPrs =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter(
"stellaops.reachability_prgate.passed",
description: "Number of PRs that passed the reachability gate");
///
/// Counter for blocked PRs.
///
public static readonly System.Diagnostics.Metrics.Counter BlockedPrs =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter(
"stellaops.reachability_prgate.blocked",
description: "Number of PRs blocked by the reachability gate");
}