Files
git.stella-ops.org/src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
2026-02-01 21:37:40 +02:00

355 lines
11 KiB
C#

// <copyright file="FacetDriftDetector.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using DotNet.Globbing;
using System.Collections.Immutable;
namespace StellaOps.Facet;
/// <summary>
/// Default implementation of <see cref="IFacetDriftDetector"/>.
/// </summary>
public sealed class FacetDriftDetector : IFacetDriftDetector
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="FacetDriftDetector"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps.</param>
public FacetDriftDetector(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public Task<FacetDriftReport> DetectDriftAsync(
FacetSeal baseline,
FacetExtractionResult current,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(baseline);
ArgumentNullException.ThrowIfNull(current);
var drifts = new List<FacetDrift>();
// Build lookup for current facets
var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId);
// Process each baseline facet
foreach (var baselineFacet in baseline.Facets)
{
ct.ThrowIfCancellationRequested();
if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet))
{
// Both have this facet - compute drift
var drift = ComputeFacetDrift(
baselineFacet,
currentFacet,
baseline.GetQuota(baselineFacet.FacetId));
drifts.Add(drift);
currentFacetLookup.Remove(baselineFacet.FacetId);
}
else
{
// Facet was removed entirely - all files are "removed"
var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId));
drifts.Add(drift);
}
}
// Remaining current facets are new
foreach (var newFacet in currentFacetLookup.Values)
{
var drift = CreateNewFacetDrift(newFacet);
drifts.Add(drift);
}
var overallVerdict = ComputeOverallVerdict(drifts);
var report = new FacetDriftReport
{
ImageDigest = baseline.ImageDigest,
BaselineSealId = baseline.CombinedMerkleRoot,
AnalyzedAt = _timeProvider.GetUtcNow(),
FacetDrifts = [.. drifts],
OverallVerdict = overallVerdict
};
return Task.FromResult(report);
}
/// <inheritdoc/>
public Task<FacetDriftReport> DetectDriftAsync(
FacetSeal baseline,
FacetSeal current,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(baseline);
ArgumentNullException.ThrowIfNull(current);
var drifts = new List<FacetDrift>();
// Build lookup for current facets
var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId);
// Process each baseline facet
foreach (var baselineFacet in baseline.Facets)
{
ct.ThrowIfCancellationRequested();
if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet))
{
// Both have this facet - compute drift
var drift = ComputeFacetDrift(
baselineFacet,
currentFacet,
baseline.GetQuota(baselineFacet.FacetId));
drifts.Add(drift);
currentFacetLookup.Remove(baselineFacet.FacetId);
}
else
{
// Facet was removed entirely
var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId));
drifts.Add(drift);
}
}
// Remaining current facets are new
foreach (var newFacet in currentFacetLookup.Values)
{
var drift = CreateNewFacetDrift(newFacet);
drifts.Add(drift);
}
var overallVerdict = ComputeOverallVerdict(drifts);
var report = new FacetDriftReport
{
ImageDigest = current.ImageDigest,
BaselineSealId = baseline.CombinedMerkleRoot,
AnalyzedAt = _timeProvider.GetUtcNow(),
FacetDrifts = [.. drifts],
OverallVerdict = overallVerdict
};
return Task.FromResult(report);
}
private static FacetDrift ComputeFacetDrift(
FacetEntry baseline,
FacetEntry current,
FacetQuota quota)
{
// Quick check: if Merkle roots match, no drift
if (baseline.MerkleRoot == current.MerkleRoot)
{
return FacetDrift.NoDrift(baseline.FacetId, baseline.FileCount);
}
// Need file-level comparison
if (baseline.Files is null || current.Files is null)
{
// Can't compute detailed drift without file entries
// Fall back to root-level drift indication
return new FacetDrift
{
FacetId = baseline.FacetId,
Added = [],
Removed = [],
Modified = [],
DriftScore = 100m, // Max drift since we can't compute details
QuotaVerdict = quota.Action switch
{
QuotaExceededAction.Block => QuotaVerdict.Blocked,
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
_ => QuotaVerdict.Warning
},
BaselineFileCount = baseline.FileCount
};
}
// Build allowlist globs
var allowlistGlobs = quota.AllowlistGlobs
.Select(p => Glob.Parse(p))
.ToList();
bool IsAllowlisted(string path) => allowlistGlobs.Any(g => g.IsMatch(path));
// Build file dictionaries
var baselineFiles = baseline.Files.Value.ToDictionary(f => f.Path);
var currentFiles = current.Files.Value.ToDictionary(f => f.Path);
var added = new List<FacetFileEntry>();
var removed = new List<FacetFileEntry>();
var modified = new List<FacetFileModification>();
// Find additions and modifications
foreach (var (path, currentFile) in currentFiles)
{
if (IsAllowlisted(path))
{
continue;
}
if (baselineFiles.TryGetValue(path, out var baselineFile))
{
// File exists in both - check for modification
if (baselineFile.Digest != currentFile.Digest)
{
modified.Add(new FacetFileModification(
path,
baselineFile.Digest,
currentFile.Digest,
baselineFile.SizeBytes,
currentFile.SizeBytes));
}
}
else
{
// File is new
added.Add(currentFile);
}
}
// Find removals
foreach (var (path, baselineFile) in baselineFiles)
{
if (IsAllowlisted(path))
{
continue;
}
if (!currentFiles.ContainsKey(path))
{
removed.Add(baselineFile);
}
}
var totalChanges = added.Count + removed.Count + modified.Count;
var driftScore = ComputeDriftScore(
added.Count,
removed.Count,
modified.Count,
baseline.FileCount);
var churnPercent = baseline.FileCount > 0
? totalChanges / (decimal)baseline.FileCount * 100
: added.Count > 0 ? 100m : 0m;
var verdict = EvaluateQuota(quota, churnPercent, totalChanges);
return new FacetDrift
{
FacetId = baseline.FacetId,
Added = [.. added],
Removed = [.. removed],
Modified = [.. modified],
DriftScore = driftScore,
QuotaVerdict = verdict,
BaselineFileCount = baseline.FileCount
};
}
private static FacetDrift CreateRemovedFacetDrift(FacetEntry baseline, FacetQuota quota)
{
var removedFiles = baseline.Files?.ToImmutableArray() ?? [];
var verdict = quota.Action switch
{
QuotaExceededAction.Block => QuotaVerdict.Blocked,
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
_ => QuotaVerdict.Warning
};
return new FacetDrift
{
FacetId = baseline.FacetId,
Added = [],
Removed = removedFiles,
Modified = [],
DriftScore = 100m,
QuotaVerdict = verdict,
BaselineFileCount = baseline.FileCount
};
}
private static FacetDrift CreateNewFacetDrift(FacetEntry newFacet)
{
var addedFiles = newFacet.Files?.ToImmutableArray() ?? [];
return new FacetDrift
{
FacetId = newFacet.FacetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = 100m, // All new = max drift from baseline perspective
QuotaVerdict = QuotaVerdict.Warning, // New facets get warning by default
BaselineFileCount = 0
};
}
private static decimal ComputeDriftScore(
int added,
int removed,
int modified,
int baselineCount)
{
if (baselineCount == 0)
{
return added > 0 ? 100m : 0m;
}
// Weighted score: additions=1.0, removals=1.0, modifications=0.5
var weightedChanges = added + removed + (modified * 0.5m);
var score = weightedChanges / baselineCount * 100;
return Math.Min(100m, score);
}
private static QuotaVerdict EvaluateQuota(FacetQuota quota, decimal churnPercent, int totalChanges)
{
var exceeds = churnPercent > quota.MaxChurnPercent ||
totalChanges > quota.MaxChangedFiles;
if (!exceeds)
{
return QuotaVerdict.Ok;
}
return quota.Action switch
{
QuotaExceededAction.Block => QuotaVerdict.Blocked,
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
_ => QuotaVerdict.Warning
};
}
private static QuotaVerdict ComputeOverallVerdict(List<FacetDrift> drifts)
{
// Return worst verdict
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Blocked))
{
return QuotaVerdict.Blocked;
}
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.RequiresVex))
{
return QuotaVerdict.RequiresVex;
}
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Warning))
{
return QuotaVerdict.Warning;
}
return QuotaVerdict.Ok;
}
}