save progress
This commit is contained in:
353
src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
Normal file
353
src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
// <copyright file="FacetDriftDetector.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using DotNet.Globbing;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user