save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -3,3 +3,4 @@
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `SIG-PG-3102-001` | `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` | DOING | Add relational call graph tables + migrations wiring; register query repository and add integration coverage. |
| `SIG-CG-3104-001` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | TODO | Resume deferred sync/projection so `signals.*` relational callgraph tables become populated and queryable. |

View File

@@ -140,4 +140,16 @@ public sealed record CallgraphEdge
/// </summary>
[JsonPropertyName("provenance")]
public string? Provenance { get; init; }
/// <summary>
/// Gates detected on this edge.
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<CallgraphGate>? Gates { get; init; }
/// <summary>
/// Combined gate multiplier in basis points (10000 = 100%).
/// </summary>
[JsonPropertyName("gateMultiplierBps")]
public int GateMultiplierBps { get; init; } = 10000;
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// A detected gate protecting a code path.
/// </summary>
public sealed record CallgraphGate
{
/// <summary>
/// Type of gate.
/// </summary>
[JsonPropertyName("type")]
public CallgraphGateType Type { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("detail")]
public string Detail { get; init; } = string.Empty;
/// <summary>
/// Symbol where gate was detected.
/// </summary>
[JsonPropertyName("guardSymbol")]
public string GuardSymbol { get; init; } = string.Empty;
/// <summary>
/// Source file (if available).
/// </summary>
[JsonPropertyName("sourceFile")]
public string? SourceFile { get; init; }
/// <summary>
/// Line number (if available).
/// </summary>
[JsonPropertyName("lineNumber")]
public int? LineNumber { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Detection method used.
/// </summary>
[JsonPropertyName("detectionMethod")]
public string DetectionMethod { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// Types of gates that can protect code paths.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CallgraphGateType
{
AuthRequired,
FeatureFlag,
AdminOnly,
NonDefaultConfig
}

View File

@@ -95,6 +95,16 @@ public sealed class ReachabilityEvidenceDocument
public List<string> RuntimeHits { get; set; } = new();
public List<string>? BlockedEdges { get; set; }
/// <summary>
/// Combined gate multiplier in basis points (10000 = 100%).
/// </summary>
public int GateMultiplierBps { get; set; } = 10000;
/// <summary>
/// Gates detected on the computed path to the target (if any).
/// </summary>
public List<CallgraphGate>? Gates { get; set; }
}
public sealed class ReachabilitySubject

View File

@@ -0,0 +1,47 @@
using System;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Options;
/// <summary>
/// Gate multiplier configuration in basis points (10000 = 100%).
/// </summary>
public sealed class SignalsGateMultiplierOptions
{
public int AuthRequiredMultiplierBps { get; set; } = 3000;
public int FeatureFlagMultiplierBps { get; set; } = 2000;
public int AdminOnlyMultiplierBps { get; set; } = 1500;
public int NonDefaultConfigMultiplierBps { get; set; } = 5000;
public int MinimumMultiplierBps { get; set; } = 500;
public int GetMultiplierBps(CallgraphGateType gateType) => gateType switch
{
CallgraphGateType.AuthRequired => AuthRequiredMultiplierBps,
CallgraphGateType.FeatureFlag => FeatureFlagMultiplierBps,
CallgraphGateType.AdminOnly => AdminOnlyMultiplierBps,
CallgraphGateType.NonDefaultConfig => NonDefaultConfigMultiplierBps,
_ => 10000
};
public void Validate()
{
EnsureBps(nameof(AuthRequiredMultiplierBps), AuthRequiredMultiplierBps);
EnsureBps(nameof(FeatureFlagMultiplierBps), FeatureFlagMultiplierBps);
EnsureBps(nameof(AdminOnlyMultiplierBps), AdminOnlyMultiplierBps);
EnsureBps(nameof(NonDefaultConfigMultiplierBps), NonDefaultConfigMultiplierBps);
EnsureBps(nameof(MinimumMultiplierBps), MinimumMultiplierBps);
}
private static void EnsureBps(string name, int value)
{
if (value < 0 || value > 10000)
{
throw new ArgumentOutOfRangeException(name, value, "Value must be between 0 and 10000.");
}
}
}

View File

@@ -7,6 +7,11 @@ namespace StellaOps.Signals.Options;
/// </summary>
public sealed class SignalsScoringOptions
{
/// <summary>
/// Gate multipliers applied when reachability paths are protected by gates.
/// </summary>
public SignalsGateMultiplierOptions GateMultipliers { get; } = new();
/// <summary>
/// Confidence assigned when a path exists from entry point to target.
/// </summary>
@@ -62,6 +67,8 @@ public sealed class SignalsScoringOptions
public void Validate()
{
GateMultipliers.Validate();
EnsurePercent(nameof(ReachableConfidence), ReachableConfidence);
EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence);
EnsurePercent(nameof(RuntimeBonus), RuntimeBonus);

View File

@@ -113,7 +113,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
}
@@ -212,7 +214,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
}
@@ -285,7 +289,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
@@ -434,4 +440,73 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
};
}
private static int? GetNullableInt(JsonElement element, string name1, string? name2 = null)
{
if (element.TryGetProperty(name1, out var v1) && v1.ValueKind == JsonValueKind.Number && v1.TryGetInt32(out var i1))
{
return i1;
}
if (!string.IsNullOrEmpty(name2)
&& element.TryGetProperty(name2!, out var v2)
&& v2.ValueKind == JsonValueKind.Number
&& v2.TryGetInt32(out var i2))
{
return i2;
}
return null;
}
private static IReadOnlyList<CallgraphGate>? ParseGates(JsonElement edgeElement)
{
if (!edgeElement.TryGetProperty("gates", out var gatesEl) || gatesEl.ValueKind != JsonValueKind.Array)
{
return null;
}
var gates = new List<CallgraphGate>(gatesEl.GetArrayLength());
foreach (var gateEl in gatesEl.EnumerateArray())
{
if (gateEl.ValueKind != JsonValueKind.Object)
{
continue;
}
var typeRaw = GetString(gateEl, "type");
if (!TryParseGateType(typeRaw, out var gateType))
{
continue;
}
gates.Add(new CallgraphGate
{
Type = gateType,
Detail = GetString(gateEl, "detail") ?? string.Empty,
GuardSymbol = GetString(gateEl, "guard_symbol", "guardSymbol") ?? string.Empty,
SourceFile = GetString(gateEl, "source_file", "sourceFile"),
LineNumber = GetNullableInt(gateEl, "line_number", "lineNumber"),
Confidence = GetNullableDouble(gateEl, "confidence") ?? 1.0,
DetectionMethod = GetString(gateEl, "detection_method", "detectionMethod") ?? string.Empty
});
}
return gates.Count == 0 ? null : gates;
}
private static bool TryParseGateType(string? raw, out CallgraphGateType gateType)
{
gateType = default;
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
var normalized = raw.Trim()
.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
return Enum.TryParse(normalized, ignoreCase: true, out gateType);
}
}

View File

@@ -127,6 +127,19 @@ internal sealed class InMemoryCallgraphRepository : ICallgraphRepository
Weight = source.Weight,
Offset = source.Offset,
IsResolved = source.IsResolved,
Provenance = source.Provenance
Provenance = source.Provenance,
GateMultiplierBps = source.GateMultiplierBps,
Gates = source.Gates?.Select(CloneGate).ToList()
};
private static CallgraphGate CloneGate(CallgraphGate source) => new()
{
Type = source.Type,
Detail = source.Detail,
GuardSymbol = source.GuardSymbol,
SourceFile = source.SourceFile,
LineNumber = source.LineNumber,
Confidence = source.Confidence,
DetectionMethod = source.DetectionMethod
};
}

View File

@@ -119,14 +119,30 @@ internal sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepo
Reachable = source.Reachable,
Confidence = source.Confidence,
Bucket = source.Bucket,
LatticeState = source.LatticeState,
PreviousLatticeState = source.PreviousLatticeState,
Weight = source.Weight,
Score = source.Score,
Path = source.Path.ToList(),
Evidence = new ReachabilityEvidenceDocument
{
RuntimeHits = source.Evidence.RuntimeHits.ToList(),
BlockedEdges = source.Evidence.BlockedEdges?.ToList()
}
BlockedEdges = source.Evidence.BlockedEdges?.ToList(),
GateMultiplierBps = source.Evidence.GateMultiplierBps,
Gates = source.Evidence.Gates?.Select(CloneGate).ToList()
},
LatticeTransitionAt = source.LatticeTransitionAt
};
private static CallgraphGate CloneGate(CallgraphGate source) => new()
{
Type = source.Type,
Detail = source.Detail,
GuardSymbol = source.GuardSymbol,
SourceFile = source.SourceFile,
LineNumber = source.LineNumber,
Confidence = source.Confidence,
DetectionMethod = source.DetectionMethod
};
private static RuntimeFactDocument CloneRuntime(RuntimeFactDocument source) => new()

View File

@@ -272,6 +272,8 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
.Append(edge.Offset?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
.Append(edge.IsResolved).Append('|')
.Append(edge.Provenance).Append('|')
.Append(edge.GateMultiplierBps.ToString(CultureInfo.InvariantCulture)).Append('|')
.Append(JoinGates(edge.Gates)).Append('|')
.Append(edge.Purl).Append('|')
.Append(edge.SymbolDigest).Append('|')
.Append(edge.Confidence?.ToString("G17", CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
@@ -330,6 +332,49 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
return ordered.ToString();
}
private static string JoinGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return string.Empty;
}
var ordered = gates
.Where(g => g is not null)
.Select(g => g with
{
GuardSymbol = g.GuardSymbol?.Trim() ?? string.Empty,
Detail = g.Detail?.Trim() ?? string.Empty,
DetectionMethod = g.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
LineNumber = g.LineNumber is > 0 ? g.LineNumber : null,
Confidence = double.IsNaN(g.Confidence) ? 0.0 : Math.Clamp(g.Confidence, 0.0, 1.0)
})
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
var builder = new StringBuilder();
foreach (var gate in ordered)
{
builder
.Append(gate.Type).Append(':')
.Append(gate.GuardSymbol).Append(':')
.Append(gate.DetectionMethod).Append(':')
.Append(gate.Confidence.ToString("G17", CultureInfo.InvariantCulture)).Append(':')
.Append(gate.SourceFile).Append(':')
.Append(gate.LineNumber?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append(':')
.Append(gate.Detail)
.Append(';');
}
return builder.ToString();
}
}
/// <summary>

View File

@@ -108,6 +108,8 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
continue;
}
var normalizedGates = NormalizeGates(edge.Gates);
list.Add(edge with
{
SourceId = source,
@@ -117,7 +119,9 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
SymbolDigest = NormalizeDigest(edge.SymbolDigest),
Confidence = ClampConfidence(edge.Confidence),
Candidates = NormalizeList(edge.Candidates),
Evidence = NormalizeList(edge.Evidence)
Evidence = NormalizeList(edge.Evidence),
Gates = normalizedGates,
GateMultiplierBps = ClampGateMultiplierBps(edge.GateMultiplierBps)
});
}
@@ -127,6 +131,47 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
.ToList();
}
private static IReadOnlyList<CallgraphGate>? NormalizeGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return null;
}
var unique = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
foreach (var gate in gates.Where(g => g is not null))
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!unique.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
unique[key] = normalized;
}
}
return unique.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
}
private static int ClampGateMultiplierBps(int multiplierBps)
=> Math.Clamp(multiplierBps, 0, 10000);
private static IReadOnlyList<CallgraphRoot> NormalizeRoots(IReadOnlyList<CallgraphRoot>? roots)
{
var list = new List<CallgraphRoot>();

View File

@@ -70,7 +70,35 @@ internal static class ReachabilityFactDigestCalculator
Score: state.Score,
Path: NormalizeList(state.Path),
RuntimeHits: NormalizeList(state.Evidence?.RuntimeHits),
BlockedEdges: NormalizeList(state.Evidence?.BlockedEdges)))
BlockedEdges: NormalizeList(state.Evidence?.BlockedEdges),
GateMultiplierBps: Math.Clamp(state.Evidence?.GateMultiplierBps ?? 10000, 0, 10000),
Gates: NormalizeGates(state.Evidence?.Gates)))
.ToList();
}
private static List<CanonicalGate> NormalizeGates(IEnumerable<CallgraphGate>? gates)
{
if (gates is null)
{
return new List<CanonicalGate>();
}
return gates
.Where(g => g is not null)
.Select(g => new CanonicalGate(
Type: g.Type.ToString(),
GuardSymbol: g.GuardSymbol?.Trim() ?? string.Empty,
DetectionMethod: g.DetectionMethod?.Trim() ?? string.Empty,
Confidence: double.IsNaN(g.Confidence) ? 0.0 : Math.Clamp(g.Confidence, 0.0, 1.0),
SourceFile: string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
LineNumber: g.LineNumber is > 0 ? g.LineNumber : null,
Detail: g.Detail?.Trim() ?? string.Empty))
.OrderBy(g => g.Type, StringComparer.Ordinal)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ToList();
}
@@ -192,7 +220,18 @@ internal static class ReachabilityFactDigestCalculator
double Score,
List<string> Path,
List<string> RuntimeHits,
List<string> BlockedEdges);
List<string> BlockedEdges,
int GateMultiplierBps,
List<CanonicalGate> Gates);
private sealed record CanonicalGate(
string Type,
string GuardSymbol,
string DetectionMethod,
double Confidence,
string? SourceFile,
int? LineNumber,
string Detail);
private sealed record CanonicalRuntimeFact(
string SymbolId,

View File

@@ -68,6 +68,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
? list
: Array.Empty<ReachabilityBlockedEdge>();
var graph = BuildGraph(callgraph, blockedEdges);
var edgeGateMap = BuildEdgeGateMap(callgraph, blockedEdges);
var entryPoints = NormalizeEntryPoints(request.EntryPoints, graph.Nodes, graph.Inbound);
var targets = request.Targets.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.Ordinal).ToList();
if (targets.Count == 0)
@@ -101,6 +102,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
var path = FindPath(entryPoints, target, graph.Adjacency);
var reachable = path is not null;
var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true).ToList();
var (pathGateMultiplierBps, pathGates) = ComputePathGateMultiplier(path, edgeGateMap);
var (bucket, weight, confidence) = ComputeScores(
reachable,
@@ -109,7 +111,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
path,
runtimeEvidence.Count);
var score = confidence * weight;
var score = confidence * weight * pathGateMultiplierBps / 10000.0;
runtimeEvidence = runtimeEvidence.OrderBy(hit => hit, StringComparer.Ordinal).ToList();
@@ -139,7 +141,9 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
BlockedEdges = request.BlockedEdges?
.Select(edge => $"{edge.From} -> {edge.To}")
.OrderBy(edge => edge, StringComparer.Ordinal)
.ToList()
.ToList(),
GateMultiplierBps = pathGateMultiplierBps,
Gates = pathGates
},
LatticeTransitionAt = previousLatticeState != latticeState.ToCode() ? computedAt : existingState?.LatticeTransitionAt
});
@@ -387,6 +391,177 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
return new ReachabilityGraph(nodes, adjacency, inbound);
}
private Dictionary<(string From, string To), EdgeGateInfo> BuildEdgeGateMap(CallgraphDocument document, IEnumerable<ReachabilityBlockedEdge> blockedEdges)
{
var blocked = new HashSet<(string From, string To)>(new ReachabilityBlockedEdgeComparer());
foreach (var blockedEdge in blockedEdges)
{
if (!string.IsNullOrWhiteSpace(blockedEdge.From) && !string.IsNullOrWhiteSpace(blockedEdge.To))
{
blocked.Add((blockedEdge.From.Trim(), blockedEdge.To.Trim()));
}
}
var map = new Dictionary<(string From, string To), EdgeGateInfo>();
foreach (var edge in document.Edges)
{
if (blocked.Contains((edge.SourceId, edge.TargetId)))
{
continue;
}
var from = edge.SourceId?.Trim();
var to = edge.TargetId?.Trim();
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
continue;
}
var normalizedGates = NormalizeGates(edge.Gates);
var multiplierBps = ComputeGateMultiplierBps(normalizedGates);
var key = (from, to);
if (!map.TryGetValue(key, out var existing))
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
continue;
}
if (multiplierBps > existing.GateMultiplierBps)
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
continue;
}
if (multiplierBps == existing.GateMultiplierBps
&& (normalizedGates?.Count ?? 0) < (existing.Gates?.Count ?? 0))
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
}
}
return map;
}
private (int GateMultiplierBps, List<CallgraphGate>? Gates) ComputePathGateMultiplier(
List<string>? path,
IReadOnlyDictionary<(string From, string To), EdgeGateInfo> edgeGateMap)
{
if (path is null || path.Count < 2)
{
return (10000, null);
}
var gatesByKey = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
for (var i = 0; i < path.Count - 1; i++)
{
var from = path[i];
var to = path[i + 1];
if (!edgeGateMap.TryGetValue((from, to), out var edgeInfo) || edgeInfo.Gates is not { Count: > 0 })
{
continue;
}
foreach (var gate in edgeInfo.Gates)
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!gatesByKey.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
gatesByKey[key] = normalized;
}
}
}
if (gatesByKey.Count == 0)
{
return (10000, null);
}
var gates = gatesByKey.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
return (ComputeGateMultiplierBps(gates), gates);
}
private int ComputeGateMultiplierBps(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return 10000;
}
var gateTypes = gates
.Select(g => g.Type)
.Distinct()
.OrderBy(t => t)
.ToList();
double multiplierBps = 10000.0;
foreach (var gateType in gateTypes)
{
multiplierBps = multiplierBps * scoringOptions.GateMultipliers.GetMultiplierBps(gateType) / 10000.0;
}
var result = (int)Math.Round(multiplierBps);
result = Math.Clamp(result, 0, 10000);
return Math.Max(result, scoringOptions.GateMultipliers.MinimumMultiplierBps);
}
private static IReadOnlyList<CallgraphGate>? NormalizeGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return null;
}
var unique = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
foreach (var gate in gates.Where(g => g is not null))
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!unique.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
unique[key] = normalized;
}
}
return unique.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
}
private static List<string> NormalizeEntryPoints(IEnumerable<string> requestedEntries, HashSet<string> nodes, Dictionary<string, HashSet<string>> inbound)
{
var entries = requestedEntries?
@@ -516,6 +691,8 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
Dictionary<string, HashSet<string>> Adjacency,
Dictionary<string, HashSet<string>> Inbound);
private sealed record EdgeGateInfo(int GateMultiplierBps, IReadOnlyList<CallgraphGate>? Gates);
private sealed class ReachabilityBlockedEdgeComparer : IEqualityComparer<(string From, string To)>
{
public bool Equals((string From, string To) x, (string From, string To) y)

View File

@@ -9,3 +9,6 @@ This file mirrors sprint work for the Signals module.
| `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. |
| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DONE (2025-12-17) | Implemented decay worker/service, signal refresh hook, and deterministic unit/integration tests. |
| `TRI-MASTER-0003` | `docs/implplan/SPRINT_3600_0001_0001_triage_unknowns_master.md` | DONE (2025-12-17) | Synced Signals AGENTS with Unknowns scoring/decay contracts and configuration sections. |
| `GATE-3405-011` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Applied gate multipliers in `ReachabilityScoringService` using path gate evidence from callgraph edges. |
| `GATE-3405-012` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Extended reachability fact evidence contract + digest to include `GateMultiplierBps` and `Gates`. |
| `GATE-3405-016` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Added deterministic parser/normalizer/scoring coverage for gate propagation + multiplier effect. |

View File

@@ -65,4 +65,62 @@ public class CallgraphNormalizationServiceTests
edge.Confidence.Should().Be(1.0);
edge.Evidence.Should().BeEquivalentTo(new[] { "x" });
}
[Fact]
public void Normalize_normalizes_gate_metadata()
{
var result = new CallgraphParseResult(
Nodes: new[]
{
new CallgraphNode("a", "a", "fn", null, null, null),
new CallgraphNode("b", "b", "fn", null, null, null)
},
Edges: new[]
{
new CallgraphEdge("a", "b", "call")
{
GateMultiplierBps = 15000,
Gates = new List<CallgraphGate>
{
new()
{
Type = CallgraphGateType.AuthRequired,
GuardSymbol = " svc.main ",
Detail = " [Authorize] ",
DetectionMethod = " attr ",
Confidence = 2.0,
SourceFile = " /src/app.cs ",
LineNumber = 0
},
new()
{
Type = CallgraphGateType.AuthRequired,
GuardSymbol = "svc.main",
Detail = "ignored",
DetectionMethod = "ignored",
Confidence = 0.1
}
}
}
},
Roots: Array.Empty<CallgraphRoot>(),
FormatVersion: "1.0",
SchemaVersion: "1.0",
Analyzer: null);
var normalized = _service.Normalize("csharp", result);
normalized.Edges.Should().ContainSingle();
var edge = normalized.Edges[0];
edge.GateMultiplierBps.Should().Be(10000);
edge.Gates.Should().NotBeNull();
edge.Gates!.Should().ContainSingle();
edge.Gates[0].Type.Should().Be(CallgraphGateType.AuthRequired);
edge.Gates[0].GuardSymbol.Should().Be("svc.main");
edge.Gates[0].Detail.Should().Be("[Authorize]");
edge.Gates[0].DetectionMethod.Should().Be("attr");
edge.Gates[0].Confidence.Should().Be(1.0);
edge.Gates[0].SourceFile.Should().Be("/src/app.cs");
edge.Gates[0].LineNumber.Should().BeNull();
}
}

View File

@@ -11,6 +11,87 @@ using Xunit;
public class ReachabilityScoringServiceTests
{
[Fact]
public async Task RecomputeAsync_applies_gate_multipliers_and_surfaces_gate_evidence()
{
var callgraph = new CallgraphDocument
{
Id = "cg-gates-1",
Language = "dotnet",
Component = "demo",
Version = "1.0.0",
Nodes = new List<CallgraphNode>
{
new("main", "Main", "method", null, null, null),
new("target", "Target", "method", null, null, null)
},
Edges = new List<CallgraphEdge>
{
new("main", "target", "call")
{
Gates = new List<CallgraphGate>
{
new()
{
Type = CallgraphGateType.AuthRequired,
GuardSymbol = "main",
Detail = "[Authorize] attribute",
DetectionMethod = "fixture",
Confidence = 0.9
}
}
}
}
};
var callgraphRepository = new InMemoryCallgraphRepository(callgraph);
var factRepository = new InMemoryReachabilityFactRepository();
var options = new SignalsOptions();
options.Scoring.ReachableConfidence = 0.8;
options.Scoring.UnreachableConfidence = 0.3;
options.Scoring.MaxConfidence = 0.95;
options.Scoring.MinConfidence = 0.1;
options.Scoring.GateMultipliers.AuthRequiredMultiplierBps = 3000;
var cache = new InMemoryReachabilityCache();
var eventsPublisher = new RecordingEventsPublisher();
var unknowns = new InMemoryUnknownsRepository();
var service = new ReachabilityScoringService(
callgraphRepository,
factRepository,
TimeProvider.System,
Options.Create(options),
cache,
unknowns,
eventsPublisher,
NullLogger<ReachabilityScoringService>.Instance);
var request = new ReachabilityRecomputeRequest
{
CallgraphId = callgraph.Id,
Subject = new ReachabilitySubject { Component = "demo", Version = "1.0.0" },
EntryPoints = new List<string> { "main" },
Targets = new List<string> { "target" }
};
var fact = await service.RecomputeAsync(request, CancellationToken.None);
Assert.Single(fact.States);
var state = fact.States[0];
Assert.True(state.Reachable);
Assert.Equal("direct", state.Bucket);
Assert.Equal(3000, state.Evidence.GateMultiplierBps);
Assert.NotNull(state.Evidence.Gates);
Assert.Contains(state.Evidence.Gates!, gate => gate.Type == CallgraphGateType.AuthRequired);
// Base score: 0.8 confidence * 0.85 direct bucket = 0.68, then auth gate (30%) = 0.204
Assert.Equal(0.204, state.Score, 3);
Assert.Equal(0.204, fact.Score, 3);
Assert.Equal(0.204, fact.RiskScore, 3);
}
[Fact]
public async Task RecomputeAsync_UsesConfiguredWeights()
{

View File

@@ -0,0 +1,62 @@
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Parsing;
using Xunit;
namespace StellaOps.Signals.Tests;
public sealed class SimpleJsonCallgraphParserGateTests
{
[Fact]
public async Task ParseAsync_parses_gate_fields_on_edges()
{
var json = """
{
"schema_version": "1.0",
"nodes": [
{ "id": "main" },
{ "id": "target" }
],
"edges": [
{
"from": "main",
"to": "target",
"kind": "call",
"gate_multiplier_bps": 3000,
"gates": [
{
"type": "authRequired",
"detail": "[Authorize] attribute",
"guard_symbol": "main",
"source_file": "/src/app.cs",
"line_number": 42,
"confidence": 0.9,
"detection_method": "pattern"
}
]
}
]
}
""";
var parser = new SimpleJsonCallgraphParser("csharp");
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json), writable: false);
var parsed = await parser.ParseAsync(stream, CancellationToken.None);
parsed.Edges.Should().ContainSingle();
var edge = parsed.Edges[0];
edge.GateMultiplierBps.Should().Be(3000);
edge.Gates.Should().NotBeNull();
edge.Gates!.Should().ContainSingle();
edge.Gates[0].Type.Should().Be(CallgraphGateType.AuthRequired);
edge.Gates[0].GuardSymbol.Should().Be("main");
edge.Gates[0].SourceFile.Should().Be("/src/app.cs");
edge.Gates[0].LineNumber.Should().Be(42);
edge.Gates[0].DetectionMethod.Should().Be("pattern");
}
}

View File

@@ -29,7 +29,7 @@ public sealed class UnknownsScoringIntegrationTests
public UnknownsScoringIntegrationTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new FullInMemoryUnknownsRepository();
_unknownsRepo = new FullInMemoryUnknownsRepository(_timeProvider);
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_defaultOptions = new UnknownsScoringOptions();
@@ -632,8 +632,14 @@ public sealed class UnknownsScoringIntegrationTests
private sealed class FullInMemoryUnknownsRepository : IUnknownsRepository
{
private readonly TimeProvider _timeProvider;
private readonly List<UnknownSymbolDocument> _stored = new();
public FullInMemoryUnknownsRepository(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
@@ -676,7 +682,7 @@ public sealed class UnknownsScoringIntegrationTests
int limit,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored
.Where(x => x.Band == band && (x.NextScheduledRescan == null || x.NextScheduledRescan <= now))