consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,26 @@
# Feedser Core Agent Charter
## Mission
Provide deterministic patch signature extraction and function signature matching for Feedser evidence collection.
## Responsibilities
- Maintain HunkSig parsing/normalization and function signature extraction.
- Ensure outputs are deterministic (stable hashes, ordering, timestamps).
- Keep regex patterns and scoring rules documented and testable.
## Required Reading
- docs/modules/feedser/architecture.md
- docs/modules/platform/architecture-overview.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
## Definition of Done
- Patch signatures are stable for identical diffs.
- Time and IDs come from injected providers or explicit inputs.
- Tests cover parsing, normalization, and function matching logic.
## Working Agreement
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
- 2. Review this charter and required docs before coding.
- 3. Prefer TimeProvider and deterministic ID generation.
- 4. Keep outputs stable (ordering, timestamps, hashes) and offline-friendly.
- 5. Revert to TODO if paused; capture context in PR notes.

View File

@@ -0,0 +1,760 @@
// -----------------------------------------------------------------------------
// FunctionSignatureExtractor.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-501 through BP-506)
// Task: Create function signature regex patterns for C, Go, Python, Rust
// Description: Extracts function signatures from patch context for Tier 3/4 matching
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Feedser.Core;
/// <summary>
/// Extracts function signatures from source code context in patches.
/// Used for Tier 3/4 evidence matching (source patch files, upstream commit mapping).
/// </summary>
public static partial class FunctionSignatureExtractor
{
/// <summary>
/// Extract function signatures from patch context lines.
/// Searches both added/removed lines and context lines for function definitions.
/// </summary>
/// <param name="contextLines">Context lines from the patch (unchanged lines around the diff).</param>
/// <param name="addedLines">Lines added in the patch.</param>
/// <param name="removedLines">Lines removed in the patch.</param>
/// <param name="filePath">File path to determine language by extension.</param>
/// <returns>Extracted function signatures.</returns>
public static ImmutableArray<ExtractedFunction> ExtractFunctionsFromContext(
IEnumerable<string> contextLines,
IEnumerable<string> addedLines,
IEnumerable<string> removedLines,
string filePath)
{
var language = DetectLanguage(filePath);
var allLines = contextLines
.Concat(addedLines)
.Concat(removedLines)
.ToList();
return language switch
{
ProgrammingLanguage.C or ProgrammingLanguage.Cpp => ExtractCFunctions(allLines, filePath),
ProgrammingLanguage.Go => ExtractGoFunctions(allLines, filePath),
ProgrammingLanguage.Python => ExtractPythonFunctions(allLines, filePath),
ProgrammingLanguage.Rust => ExtractRustFunctions(allLines, filePath),
ProgrammingLanguage.Java => ExtractJavaFunctions(allLines, filePath),
ProgrammingLanguage.JavaScript or ProgrammingLanguage.TypeScript => ExtractJavaScriptFunctions(allLines, filePath),
_ => ImmutableArray<ExtractedFunction>.Empty
};
}
/// <summary>
/// Detect programming language from file extension.
/// </summary>
public static ProgrammingLanguage DetectLanguage(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".c" or ".h" => ProgrammingLanguage.C,
".cpp" or ".cc" or ".cxx" or ".hpp" or ".hxx" or ".hh" => ProgrammingLanguage.Cpp,
".go" => ProgrammingLanguage.Go,
".py" or ".pyw" => ProgrammingLanguage.Python,
".rs" => ProgrammingLanguage.Rust,
".java" => ProgrammingLanguage.Java,
".js" or ".mjs" or ".cjs" => ProgrammingLanguage.JavaScript,
".ts" or ".tsx" => ProgrammingLanguage.TypeScript,
".cs" => ProgrammingLanguage.CSharp,
".rb" => ProgrammingLanguage.Ruby,
".php" => ProgrammingLanguage.Php,
".swift" => ProgrammingLanguage.Swift,
".kt" or ".kts" => ProgrammingLanguage.Kotlin,
_ => ProgrammingLanguage.Unknown
};
}
#region C/C++ Function Extraction (BP-503)
/// <summary>
/// Extract C/C++ function signatures.
/// Patterns: "return_type function_name(params)" or "return_type* function_name(params)"
/// </summary>
private static ImmutableArray<ExtractedFunction> ExtractCFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
// Match C function definitions
// Pattern: type [modifiers] name(params) { or type [modifiers] name(params);
var match = CFunctionRegex().Match(line);
if (match.Success)
{
var returnType = match.Groups["return"].Value.Trim();
var funcName = match.Groups["name"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
// Skip common false positives
if (IsCFalsePositive(funcName))
{
continue;
}
var signature = $"{returnType} {funcName}({params_})";
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.C,
FilePath = filePath,
Confidence = 0.85
});
}
}
}
return functions.ToImmutable();
}
private static bool IsCFalsePositive(string name)
{
// Skip common keywords that regex might match
return name is "if" or "else" or "for" or "while" or "switch" or "return"
or "sizeof" or "typeof" or "alignof" or "offsetof"
or "defined" or "pragma";
}
/// <summary>
/// Regex for C/C++ function definitions.
/// Matches: return_type [*] function_name(params)
/// </summary>
[GeneratedRegex(@"^\s*(?<return>(?:static\s+|inline\s+|extern\s+|const\s+|unsigned\s+|signed\s+|struct\s+|enum\s+)*[\w*&:\[\]]+(?:\s*\*+)?)\s+(?<name>\w+)\s*\((?<params>[^)]*)\)\s*(?:\{|;|\s*$)", RegexOptions.Compiled)]
private static partial Regex CFunctionRegex();
#endregion
#region Go Function Extraction (BP-504)
/// <summary>
/// Extract Go function signatures.
/// Patterns: "func name(params) return" or "func (receiver) name(params) return"
/// </summary>
private static ImmutableArray<ExtractedFunction> ExtractGoFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
var match = GoFunctionRegex().Match(line);
if (match.Success)
{
var receiver = match.Groups["receiver"].Value.Trim();
var funcName = match.Groups["name"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
var returns = match.Groups["returns"].Value.Trim();
var signature = string.IsNullOrEmpty(receiver)
? $"func {funcName}({params_})"
: $"func ({receiver}) {funcName}({params_})";
if (!string.IsNullOrEmpty(returns))
{
signature += $" {returns}";
}
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.Go,
FilePath = filePath,
Receiver = string.IsNullOrEmpty(receiver) ? null : receiver,
Confidence = 0.90
});
}
}
}
return functions.ToImmutable();
}
/// <summary>
/// Regex for Go function definitions.
/// Matches: func [(*receiver Type)] name(params) [returns]
/// </summary>
[GeneratedRegex(@"^\s*func\s+(?:\((?<receiver>[^)]+)\)\s+)?(?<name>\w+)\s*\((?<params>[^)]*)\)(?:\s*(?<returns>[\w*\[\]\(\),\s]+))?\s*\{?", RegexOptions.Compiled)]
private static partial Regex GoFunctionRegex();
#endregion
#region Python Function Extraction (BP-505)
/// <summary>
/// Extract Python function signatures.
/// Patterns: "def name(params):" or "async def name(params):"
/// </summary>
private static ImmutableArray<ExtractedFunction> ExtractPythonFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
var match = PythonFunctionRegex().Match(line);
if (match.Success)
{
var isAsync = match.Groups["async"].Success;
var funcName = match.Groups["name"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
var returnType = match.Groups["return"].Value.Trim();
var signature = isAsync ? $"async def {funcName}({params_})" : $"def {funcName}({params_})";
if (!string.IsNullOrEmpty(returnType))
{
signature += $" -> {returnType}";
}
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.Python,
FilePath = filePath,
IsAsync = isAsync,
Confidence = 0.85
});
}
}
}
return functions.ToImmutable();
}
/// <summary>
/// Regex for Python function definitions.
/// Matches: [async] def name(params) [-> return_type]:
/// </summary>
[GeneratedRegex(@"^\s*(?<async>async\s+)?def\s+(?<name>\w+)\s*\((?<params>[^)]*)\)(?:\s*->\s*(?<return>[\w\[\],\s|]+))?\s*:", RegexOptions.Compiled)]
private static partial Regex PythonFunctionRegex();
#endregion
#region Rust Function Extraction (BP-506)
/// <summary>
/// Extract Rust function signatures.
/// Patterns: "fn name(params) -> return" or "pub fn name(params)"
/// </summary>
private static ImmutableArray<ExtractedFunction> ExtractRustFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
var match = RustFunctionRegex().Match(line);
if (match.Success)
{
var visibility = match.Groups["vis"].Value.Trim();
var isAsync = match.Groups["async"].Success;
var funcName = match.Groups["name"].Value.Trim();
var generics = match.Groups["generics"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
var returns = match.Groups["returns"].Value.Trim();
var signature = "";
if (!string.IsNullOrEmpty(visibility))
{
signature += visibility + " ";
}
if (isAsync)
{
signature += "async ";
}
signature += $"fn {funcName}";
if (!string.IsNullOrEmpty(generics))
{
signature += generics;
}
signature += $"({params_})";
if (!string.IsNullOrEmpty(returns))
{
signature += $" -> {returns}";
}
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.Rust,
FilePath = filePath,
IsAsync = isAsync,
Confidence = 0.90
});
}
}
}
return functions.ToImmutable();
}
/// <summary>
/// Regex for Rust function definitions.
/// Matches: [pub|pub(crate)] [async] [unsafe] fn name[<generics>](params) [-> return_type]
/// </summary>
[GeneratedRegex(@"^\s*(?<vis>pub(?:\s*\([^)]+\))?\s+)?(?<async>async\s+)?(?:unsafe\s+)?fn\s+(?<name>\w+)(?<generics><[^>]+>)?\s*\((?<params>[^)]*)\)(?:\s*->\s*(?<returns>[\w<>&*'\[\],\s]+))?\s*(?:where|{)?", RegexOptions.Compiled)]
private static partial Regex RustFunctionRegex();
#endregion
#region Java Function Extraction
private static ImmutableArray<ExtractedFunction> ExtractJavaFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
var match = JavaMethodRegex().Match(line);
if (match.Success)
{
var modifiers = match.Groups["mods"].Value.Trim();
var returnType = match.Groups["return"].Value.Trim();
var methodName = match.Groups["name"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
// Skip constructors (name == class name, no return type in signature)
if (string.IsNullOrEmpty(returnType))
{
continue;
}
var signature = $"{modifiers} {returnType} {methodName}({params_})".Trim();
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = methodName,
Signature = signature,
Language = ProgrammingLanguage.Java,
FilePath = filePath,
Confidence = 0.85
});
}
}
}
return functions.ToImmutable();
}
// Note: mods captures all modifiers as a single group (not repeated)
[GeneratedRegex(@"^\s*(?<mods>(?:(?:public|private|protected|static|final|abstract|synchronized|native|strictfp)\s+)+)?(?<return>[\w<>\[\],\s]+)\s+(?<name>\w+)\s*\((?<params>[^)]*)\)\s*(?:throws\s+[\w,\s]+)?\s*\{?", RegexOptions.Compiled)]
private static partial Regex JavaMethodRegex();
#endregion
#region JavaScript/TypeScript Function Extraction
private static ImmutableArray<ExtractedFunction> ExtractJavaScriptFunctions(List<string> lines, string filePath)
{
var functions = ImmutableArray.CreateBuilder<ExtractedFunction>();
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in lines)
{
// Regular function
var match = JsFunctionRegex().Match(line);
if (match.Success)
{
var isAsync = match.Groups["async"].Success;
var funcName = match.Groups["name"].Value.Trim();
var params_ = match.Groups["params"].Value.Trim();
var signature = isAsync ? $"async function {funcName}({params_})" : $"function {funcName}({params_})";
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.JavaScript,
FilePath = filePath,
IsAsync = isAsync,
Confidence = 0.80
});
}
}
// Arrow function or method
var arrowMatch = JsArrowFunctionRegex().Match(line);
if (arrowMatch.Success)
{
var funcName = arrowMatch.Groups["name"].Value.Trim();
var params_ = arrowMatch.Groups["params"].Value.Trim();
var signature = $"const {funcName} = ({params_}) =>";
if (seenSignatures.Add(signature))
{
functions.Add(new ExtractedFunction
{
Name = funcName,
Signature = signature,
Language = ProgrammingLanguage.JavaScript,
FilePath = filePath,
Confidence = 0.75
});
}
}
}
return functions.ToImmutable();
}
[GeneratedRegex(@"^\s*(?:export\s+)?(?<async>async\s+)?function\s+(?<name>\w+)\s*\((?<params>[^)]*)\)", RegexOptions.Compiled)]
private static partial Regex JsFunctionRegex();
[GeneratedRegex(@"^\s*(?:export\s+)?(?:const|let|var)\s+(?<name>\w+)\s*=\s*(?:async\s+)?\(?(?<params>[^)=]*)\)?\s*=>", RegexOptions.Compiled)]
private static partial Regex JsArrowFunctionRegex();
#endregion
}
/// <summary>
/// Represents an extracted function signature from source code.
/// </summary>
public sealed record ExtractedFunction
{
/// <summary>
/// The function/method name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The full signature as extracted.
/// </summary>
public required string Signature { get; init; }
/// <summary>
/// The programming language.
/// </summary>
public required ProgrammingLanguage Language { get; init; }
/// <summary>
/// Source file path.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Confidence in the extraction (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// For methods, the receiver/type (e.g., Go receiver, C++ class).
/// </summary>
public string? Receiver { get; init; }
/// <summary>
/// Whether the function is async.
/// </summary>
public bool IsAsync { get; init; }
}
/// <summary>
/// Result of a fuzzy function match comparison.
/// </summary>
public sealed record FunctionMatchResult
{
/// <summary>
/// The source function being compared.
/// </summary>
public required ExtractedFunction Source { get; init; }
/// <summary>
/// The target function being matched against.
/// </summary>
public required ExtractedFunction Target { get; init; }
/// <summary>
/// Overall match score from 0.0 to 1.0.
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Individual component scores for debugging/audit.
/// </summary>
public required FunctionMatchScores ComponentScores { get; init; }
/// <summary>
/// Whether this is considered a strong match (score >= 0.70).
/// </summary>
public bool IsStrongMatch => Score >= 0.70;
/// <summary>
/// Whether this is considered a weak match (score >= 0.40).
/// </summary>
public bool IsWeakMatch => Score >= 0.40;
}
/// <summary>
/// Component scores for function matching.
/// </summary>
public sealed record FunctionMatchScores
{
/// <summary>
/// Name similarity score (0.0 to 1.0).
/// </summary>
public required double NameScore { get; init; }
/// <summary>
/// Signature similarity score (0.0 to 1.0).
/// </summary>
public required double SignatureScore { get; init; }
/// <summary>
/// Language match bonus (0.0 or 1.0).
/// </summary>
public required double LanguageBonus { get; init; }
}
/// <summary>
/// Supported programming languages for function extraction.
/// </summary>
public enum ProgrammingLanguage
{
Unknown,
C,
Cpp,
Go,
Python,
Rust,
Java,
JavaScript,
TypeScript,
CSharp,
Ruby,
Php,
Swift,
Kotlin
}
/// <summary>
/// Extension methods for fuzzy function matching (BP-508).
/// </summary>
public static partial class FunctionMatchingExtensions
{
/// <summary>
/// Weight for function name similarity.
/// </summary>
private const double NameWeight = 0.50;
/// <summary>
/// Weight for signature similarity.
/// </summary>
private const double SignatureWeight = 0.35;
/// <summary>
/// Bonus for matching language.
/// </summary>
private const double LanguageBonus = 0.15;
/// <summary>
/// Find the best matching function from a candidate set.
/// </summary>
/// <param name="source">Source function to match.</param>
/// <param name="candidates">Candidate functions to match against.</param>
/// <param name="minScore">Minimum score threshold (default 0.40).</param>
/// <returns>Best match result, or null if no match above threshold.</returns>
public static FunctionMatchResult? FindBestMatch(
this ExtractedFunction source,
IEnumerable<ExtractedFunction> candidates,
double minScore = 0.40)
{
FunctionMatchResult? bestMatch = null;
foreach (var candidate in candidates)
{
var result = ComputeMatch(source, candidate);
if (result.Score >= minScore && (bestMatch is null || result.Score > bestMatch.Score))
{
bestMatch = result;
}
}
return bestMatch;
}
/// <summary>
/// Find all matching functions above a threshold.
/// </summary>
/// <param name="source">Source function to match.</param>
/// <param name="candidates">Candidate functions to match against.</param>
/// <param name="minScore">Minimum score threshold (default 0.40).</param>
/// <returns>All matches above threshold, ordered by score descending.</returns>
public static ImmutableArray<FunctionMatchResult> FindAllMatches(
this ExtractedFunction source,
IEnumerable<ExtractedFunction> candidates,
double minScore = 0.40)
{
return candidates
.Select(c => ComputeMatch(source, c))
.Where(r => r.Score >= minScore)
.OrderByDescending(r => r.Score)
.ToImmutableArray();
}
/// <summary>
/// Compute the match score between two functions.
/// </summary>
public static FunctionMatchResult ComputeMatch(ExtractedFunction source, ExtractedFunction target)
{
// Name similarity (Levenshtein-based)
var nameScore = ComputeStringSimilarity(source.Name, target.Name);
// Signature similarity (normalized Levenshtein)
var signatureScore = ComputeStringSimilarity(
NormalizeSignature(source.Signature),
NormalizeSignature(target.Signature));
// Language bonus
var languageBonus = AreLanguagesCompatible(source.Language, target.Language) ? 1.0 : 0.0;
// Weighted total
var score = (nameScore * NameWeight) +
(signatureScore * SignatureWeight) +
(languageBonus * LanguageBonus);
return new FunctionMatchResult
{
Source = source,
Target = target,
Score = Math.Min(1.0, score), // Cap at 1.0
ComponentScores = new FunctionMatchScores
{
NameScore = nameScore,
SignatureScore = signatureScore,
LanguageBonus = languageBonus
}
};
}
/// <summary>
/// Compute string similarity using normalized Levenshtein distance.
/// Returns a value from 0.0 (no similarity) to 1.0 (identical).
/// </summary>
private static double ComputeStringSimilarity(string a, string b)
{
if (string.IsNullOrEmpty(a) && string.IsNullOrEmpty(b))
return 1.0;
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b))
return 0.0;
if (string.Equals(a, b, StringComparison.Ordinal))
return 1.0;
// Case-insensitive comparison gives partial credit
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
return 0.95;
var distance = LevenshteinDistance(a, b);
var maxLen = Math.Max(a.Length, b.Length);
return 1.0 - ((double)distance / maxLen);
}
/// <summary>
/// Compute Levenshtein edit distance between two strings.
/// </summary>
private static int LevenshteinDistance(string a, string b)
{
var n = a.Length;
var m = b.Length;
if (n == 0) return m;
if (m == 0) return n;
// Use two-row optimization to reduce memory
var prev = new int[m + 1];
var curr = new int[m + 1];
for (var j = 0; j <= m; j++)
prev[j] = j;
for (var i = 1; i <= n; i++)
{
curr[0] = i;
for (var j = 1; j <= m; j++)
{
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = Math.Min(
Math.Min(prev[j] + 1, curr[j - 1] + 1),
prev[j - 1] + cost);
}
(prev, curr) = (curr, prev);
}
return prev[m];
}
/// <summary>
/// Normalize signature for comparison (remove whitespace variations, etc).
/// </summary>
private static string NormalizeSignature(string signature)
{
// Remove excessive whitespace
var normalized = WhitespaceRegex().Replace(signature.Trim(), " ");
// Remove common decorators that don't affect identity
normalized = normalized
.Replace("const ", "")
.Replace("static ", "")
.Replace("inline ", "")
.Replace("extern ", "")
.Replace("virtual ", "")
.Replace("override ", "")
.Replace("final ", "");
return normalized;
}
[GeneratedRegex(@"\s+", RegexOptions.Compiled)]
private static partial Regex WhitespaceRegex();
/// <summary>
/// Check if two programming languages are compatible for matching.
/// </summary>
private static bool AreLanguagesCompatible(ProgrammingLanguage a, ProgrammingLanguage b)
{
if (a == b) return true;
// C and C++ are compatible
if ((a is ProgrammingLanguage.C or ProgrammingLanguage.Cpp) &&
(b is ProgrammingLanguage.C or ProgrammingLanguage.Cpp))
return true;
// JavaScript and TypeScript are compatible
if ((a is ProgrammingLanguage.JavaScript or ProgrammingLanguage.TypeScript) &&
(b is ProgrammingLanguage.JavaScript or ProgrammingLanguage.TypeScript))
return true;
return false;
}
}

View File

@@ -0,0 +1,258 @@
using StellaOps.Feedser.Core.Models;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Feedser.Core;
/// <summary>
/// Extracts and normalizes patch signatures (HunkSig) from Git diffs.
/// </summary>
public static partial class HunkSigExtractor
{
private const string Version = "1.0.0";
/// <summary>
/// Extract patch signature from unified diff.
/// </summary>
public static PatchSignature ExtractFromDiff(
string cveId,
string upstreamRepo,
string commitSha,
string unifiedDiff,
TimeProvider? timeProvider = null)
{
var time = timeProvider ?? TimeProvider.System;
var hunks = ParseUnifiedDiff(unifiedDiff);
var normalizedHunks = hunks.Select(NormalizeHunk).ToList();
var hunkHash = ComputeHunkHash(normalizedHunks);
var affectedFiles = normalizedHunks
.Select(h => h.FilePath)
.Distinct()
.OrderBy(f => f, StringComparer.Ordinal)
.ToList();
// Extract affected functions from patch context
var affectedFunctions = ExtractAffectedFunctions(normalizedHunks);
return new PatchSignature
{
PatchSigId = $"sha256:{hunkHash}",
CveId = cveId,
UpstreamRepo = upstreamRepo,
CommitSha = commitSha,
Hunks = normalizedHunks,
HunkHash = hunkHash,
AffectedFiles = affectedFiles,
AffectedFunctions = affectedFunctions.Count > 0 ? affectedFunctions : null,
ExtractedAt = time.GetUtcNow(),
ExtractorVersion = Version
};
}
/// <summary>
/// Extracts affected function names from patch hunks using FunctionSignatureExtractor.
/// </summary>
private static List<string> ExtractAffectedFunctions(IReadOnlyList<PatchHunk> hunks)
{
var functions = new HashSet<string>(StringComparer.Ordinal);
foreach (var hunk in hunks)
{
var contextLines = string.IsNullOrEmpty(hunk.Context)
? Array.Empty<string>()
: hunk.Context.Split('\n');
var extracted = FunctionSignatureExtractor.ExtractFunctionsFromContext(
contextLines,
hunk.AddedLines,
hunk.RemovedLines,
hunk.FilePath);
foreach (var func in extracted)
{
functions.Add(func.Name);
}
}
return functions.OrderBy(f => f, StringComparer.Ordinal).ToList();
}
private static List<PatchHunk> ParseUnifiedDiff(string diff)
{
var hunks = new List<PatchHunk>();
// Normalize line endings to handle both \n and \r\n
var lines = diff.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
string? currentFile = null;
int currentStartLine = 0;
var context = new List<string>();
var added = new List<string>();
var removed = new List<string>();
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
// File header
if (line.StartsWith("--- ") || line.StartsWith("+++ "))
{
// Save previous hunk before starting new file
if (line.StartsWith("--- ") && currentFile != null && (added.Count > 0 || removed.Count > 0))
{
hunks.Add(CreateHunk(currentFile, currentStartLine, context, added, removed));
context.Clear();
added.Clear();
removed.Clear();
}
if (line.StartsWith("+++ "))
{
currentFile = ExtractFilePath(line);
}
continue;
}
// Hunk header
if (line.StartsWith("@@ "))
{
// Save previous hunk if exists
if (currentFile != null && (added.Count > 0 || removed.Count > 0))
{
hunks.Add(CreateHunk(currentFile, currentStartLine, context, added, removed));
context.Clear();
added.Clear();
removed.Clear();
}
currentStartLine = ExtractStartLine(line);
continue;
}
// Content lines
if (currentFile != null)
{
if (line.StartsWith("+"))
{
added.Add(line[1..]);
}
else if (line.StartsWith("-"))
{
removed.Add(line[1..]);
}
else if (line.StartsWith(" "))
{
context.Add(line[1..]);
}
}
}
// Save last hunk
if (currentFile != null && (added.Count > 0 || removed.Count > 0))
{
hunks.Add(CreateHunk(currentFile, currentStartLine, context, added, removed));
}
return hunks;
}
private static PatchHunk NormalizeHunk(PatchHunk hunk)
{
// Normalize: strip whitespace, lowercase, remove comments
var normalizedAdded = hunk.AddedLines
.Select(NormalizeLine)
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
var normalizedRemoved = hunk.RemovedLines
.Select(NormalizeLine)
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
var hunkContent = string.Join("\n", normalizedAdded) + "\n" + string.Join("\n", normalizedRemoved);
var hunkHash = ComputeSha256(hunkContent);
return hunk with
{
AddedLines = normalizedAdded,
RemovedLines = normalizedRemoved,
HunkHash = hunkHash
};
}
private static string NormalizeLine(string line)
{
// Remove leading/trailing whitespace
line = line.Trim();
// Remove C-style comments
line = CCommentRegex().Replace(line, "");
// Normalize whitespace
line = WhitespaceRegex().Replace(line, " ");
return line;
}
private static string ComputeHunkHash(IReadOnlyList<PatchHunk> hunks)
{
var combined = string.Join("\n", hunks.Select(h => h.HunkHash).OrderBy(h => h));
return ComputeSha256(combined);
}
private static string ComputeSha256(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static PatchHunk CreateHunk(
string filePath,
int startLine,
List<string> context,
List<string> added,
List<string> removed)
{
return new PatchHunk
{
FilePath = filePath,
StartLine = startLine,
Context = string.Join("\n", context),
AddedLines = added.ToList(),
RemovedLines = removed.ToList(),
HunkHash = "" // Will be computed during normalization
};
}
private static string ExtractFilePath(string line)
{
// "+++ b/path/to/file" - trim CR/LF first
line = line.TrimEnd('\r', '\n');
var match = FilePathRegex().Match(line);
return match.Success ? match.Groups[1].Value : "";
}
private static int ExtractStartLine(string line)
{
// "@@ -123,45 +123,47 @@"
var match = HunkHeaderRegex().Match(line);
return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
}
[GeneratedRegex(@"\+\+\+ [ab]/(.+)")]
private static partial Regex FilePathRegex();
[GeneratedRegex(@"@@ -(\d+),\d+ \+\d+,\d+ @@")]
private static partial Regex HunkHeaderRegex();
[GeneratedRegex(@"/\*.*?\*/|//.*")]
private static partial Regex CCommentRegex();
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
}

View File

@@ -0,0 +1,31 @@
namespace StellaOps.Feedser.Core.Models;
/// <summary>
/// Patch signature (HunkSig) for equivalence matching.
/// </summary>
public sealed record PatchSignature
{
public required string PatchSigId { get; init; }
public required string? CveId { get; init; }
public required string UpstreamRepo { get; init; }
public required string CommitSha { get; init; }
public required IReadOnlyList<PatchHunk> Hunks { get; init; }
public required string HunkHash { get; init; }
public required IReadOnlyList<string> AffectedFiles { get; init; }
public required IReadOnlyList<string>? AffectedFunctions { get; init; }
public required DateTimeOffset ExtractedAt { get; init; }
public required string ExtractorVersion { get; init; }
}
/// <summary>
/// Normalized patch hunk for matching.
/// </summary>
public sealed record PatchHunk
{
public required string FilePath { get; init; }
public required int StartLine { get; init; }
public required string Context { get; init; }
public required IReadOnlyList<string> AddedLines { get; init; }
public required IReadOnlyList<string> RemovedLines { get; init; }
public required string HunkHash { get; init; }
}

View File

@@ -0,0 +1,207 @@
// -----------------------------------------------------------------------------
// EpssSignalAttacher.cs
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
// Task: DBI-002 - Implement EpssSignalAttacher with event emission
// Description: Attaches EPSS signals to the determinization pipeline
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Feedser.Core.Signals;
/// <summary>
/// Input for EPSS signal lookup.
/// </summary>
public sealed record EpssLookupInput
{
/// <summary>The CVE ID to look up.</summary>
public required string CveId { get; init; }
/// <summary>Optional date for historical lookup.</summary>
public DateOnly? AsOfDate { get; init; }
}
/// <summary>
/// EPSS signal value.
/// </summary>
public sealed record EpssSignal
{
/// <summary>The CVE ID.</summary>
public required string CveId { get; init; }
/// <summary>EPSS probability (0.0-1.0).</summary>
public required double Score { get; init; }
/// <summary>EPSS percentile (0.0-100.0).</summary>
public required double Percentile { get; init; }
/// <summary>The date of the EPSS score.</summary>
public required DateOnly ScoreDate { get; init; }
/// <summary>Model version used to compute the score.</summary>
public string? ModelVersion { get; init; }
}
/// <summary>
/// Attaches EPSS signals from the EPSS feed.
/// </summary>
public sealed class EpssSignalAttacher : ISignalAttacher<EpssLookupInput, EpssSignal>
{
private readonly IEpssDataSource _dataSource;
private readonly ISignalEventEmitter _eventEmitter;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssSignalAttacher> _logger;
public string SignalType => "epss";
public EpssSignalAttacher(
IEpssDataSource dataSource,
ISignalEventEmitter eventEmitter,
TimeProvider timeProvider,
ILogger<EpssSignalAttacher> logger)
{
_dataSource = dataSource;
_eventEmitter = eventEmitter;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<SignalState<EpssSignal>> AttachAsync(
EpssLookupInput input,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
try
{
var epss = await _dataSource.GetEpssAsync(input.CveId, input.AsOfDate, ct);
if (epss is null)
{
_logger.LogDebug("EPSS not found for CVE {CveId}", input.CveId);
var notFoundState = SignalState<EpssSignal>.NotFound(now, "epss-feed");
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
{
SignalType = SignalType,
CveId = input.CveId,
Status = SignalStatus.NotFound,
UpdatedAt = now
}, ct);
return notFoundState;
}
var signal = new EpssSignal
{
CveId = input.CveId,
Score = epss.Score,
Percentile = epss.Percentile,
ScoreDate = epss.ScoreDate,
ModelVersion = epss.ModelVersion
};
var state = SignalState<EpssSignal>.Success(
signal,
now,
"epss-feed",
TimeSpan.FromHours(24)); // EPSS updates daily
_logger.LogDebug(
"EPSS attached for CVE {CveId}: score={Score}, percentile={Percentile}",
input.CveId, epss.Score, epss.Percentile);
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
{
SignalType = SignalType,
CveId = input.CveId,
Status = SignalStatus.Available,
UpdatedAt = now
}, ct);
return state;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to attach EPSS for CVE {CveId}", input.CveId);
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
{
SignalType = SignalType,
CveId = input.CveId,
Status = SignalStatus.Failed,
UpdatedAt = now
}, ct);
return SignalState<EpssSignal>.Failed(ex.Message, now, "epss-feed");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<SignalState<EpssSignal>>> AttachBatchAsync(
IReadOnlyList<EpssLookupInput> inputs,
CancellationToken ct = default)
{
var results = new List<SignalState<EpssSignal>>(inputs.Count);
// Process in parallel with bounded concurrency
var tasks = inputs.Select(async input =>
{
return await AttachAsync(input, ct);
});
var states = await Task.WhenAll(tasks);
results.AddRange(states);
return results;
}
}
/// <summary>
/// Data source for EPSS scores.
/// </summary>
public interface IEpssDataSource
{
/// <summary>
/// Gets EPSS data for a CVE.
/// </summary>
Task<EpssData?> GetEpssAsync(string cveId, DateOnly? asOfDate = null, CancellationToken ct = default);
/// <summary>
/// Gets EPSS data for multiple CVEs.
/// </summary>
Task<IReadOnlyDictionary<string, EpssData>> GetEpssBatchAsync(
IReadOnlyList<string> cveIds,
DateOnly? asOfDate = null,
CancellationToken ct = default);
}
/// <summary>
/// Raw EPSS data from the feed.
/// </summary>
public sealed record EpssData
{
public required string CveId { get; init; }
public required double Score { get; init; }
public required double Percentile { get; init; }
public required DateOnly ScoreDate { get; init; }
public string? ModelVersion { get; init; }
}
/// <summary>
/// Emits signal update events.
/// </summary>
public interface ISignalEventEmitter
{
/// <summary>
/// Emits a signal updated event.
/// </summary>
Task EmitAsync(SignalUpdatedEvent @event, CancellationToken ct = default);
/// <summary>
/// Emits multiple events in batch.
/// </summary>
Task EmitBatchAsync(IReadOnlyList<SignalUpdatedEvent> events, CancellationToken ct = default);
}

View File

@@ -0,0 +1,154 @@
// -----------------------------------------------------------------------------
// ISignalAttacher.cs
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
// Task: DBI-001 - Create ISignalAttacher<T> interface in Feedser
// Description: Interface for attaching signals to determinization pipeline
// -----------------------------------------------------------------------------
namespace StellaOps.Feedser.Core.Signals;
/// <summary>
/// Attaches signals from feed data to the determinization pipeline.
/// </summary>
/// <typeparam name="TInput">The feed data input type.</typeparam>
/// <typeparam name="TSignal">The signal output type.</typeparam>
public interface ISignalAttacher<TInput, TSignal>
{
/// <summary>
/// Attaches a signal from the feed data.
/// </summary>
/// <param name="input">The feed data input.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signal state wrapping the result.</returns>
Task<SignalState<TSignal>> AttachAsync(TInput input, CancellationToken ct = default);
/// <summary>
/// Attaches signals in batch.
/// </summary>
/// <param name="inputs">The feed data inputs.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Signal states for each input.</returns>
Task<IReadOnlyList<SignalState<TSignal>>> AttachBatchAsync(
IReadOnlyList<TInput> inputs,
CancellationToken ct = default);
/// <summary>
/// The signal type identifier.
/// </summary>
string SignalType { get; }
}
/// <summary>
/// Represents the state of a signal in the determinization pipeline.
/// </summary>
/// <typeparam name="T">The signal value type.</typeparam>
public sealed record SignalState<T>
{
/// <summary>The signal value (if available).</summary>
public T? Value { get; init; }
/// <summary>The status of the signal.</summary>
public required SignalStatus Status { get; init; }
/// <summary>When the signal was captured.</summary>
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Time-to-live for the signal.</summary>
public TimeSpan? Ttl { get; init; }
/// <summary>Error message if status is Failed.</summary>
public string? Error { get; init; }
/// <summary>Source identifier for provenance.</summary>
public string? Source { get; init; }
/// <summary>Creates a successful signal state.</summary>
public static SignalState<T> Success(T value, DateTimeOffset capturedAt, string? source = null, TimeSpan? ttl = null)
=> new()
{
Value = value,
Status = SignalStatus.Available,
CapturedAt = capturedAt,
Source = source,
Ttl = ttl
};
/// <summary>Creates a not-found signal state.</summary>
public static SignalState<T> NotFound(DateTimeOffset capturedAt, string? source = null)
=> new()
{
Status = SignalStatus.NotFound,
CapturedAt = capturedAt,
Source = source
};
/// <summary>Creates a failed signal state.</summary>
public static SignalState<T> Failed(string error, DateTimeOffset capturedAt, string? source = null)
=> new()
{
Status = SignalStatus.Failed,
Error = error,
CapturedAt = capturedAt,
Source = source
};
/// <summary>Creates a pending signal state.</summary>
public static SignalState<T> Pending(DateTimeOffset capturedAt, string? source = null)
=> new()
{
Status = SignalStatus.Pending,
CapturedAt = capturedAt,
Source = source
};
}
/// <summary>
/// Status of a signal in the determinization pipeline.
/// </summary>
public enum SignalStatus
{
/// <summary>Signal is available and has a value.</summary>
Available,
/// <summary>Signal lookup returned no result.</summary>
NotFound,
/// <summary>Signal lookup failed with an error.</summary>
Failed,
/// <summary>Signal is pending (async lookup in progress).</summary>
Pending,
/// <summary>Signal has expired (past TTL).</summary>
Expired,
/// <summary>Signal source is not configured.</summary>
NotConfigured
}
/// <summary>
/// Event emitted when a signal is updated.
/// </summary>
public sealed record SignalUpdatedEvent
{
/// <summary>The signal type.</summary>
public required string SignalType { get; init; }
/// <summary>The CVE ID (if applicable).</summary>
public string? CveId { get; init; }
/// <summary>The package URL (if applicable).</summary>
public string? Purl { get; init; }
/// <summary>The new status.</summary>
public required SignalStatus Status { get; init; }
/// <summary>When the update occurred.</summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>Previous status (for transitions).</summary>
public SignalStatus? PreviousStatus { get; init; }
/// <summary>Correlation ID for tracing.</summary>
public string? CorrelationId { get; init; }
}

View File

@@ -0,0 +1,242 @@
// -----------------------------------------------------------------------------
// KevSignalAttacher.cs
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
// Task: DBI-003 - Implement KevSignalAttacher
// Description: Attaches KEV (Known Exploited Vulnerabilities) signals
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Feedser.Core.Signals;
/// <summary>
/// Input for KEV signal lookup.
/// </summary>
public sealed record KevLookupInput
{
/// <summary>The CVE ID to look up.</summary>
public required string CveId { get; init; }
}
/// <summary>
/// KEV signal value.
/// </summary>
public sealed record KevSignal
{
/// <summary>The CVE ID.</summary>
public required string CveId { get; init; }
/// <summary>Whether the CVE is in KEV.</summary>
public required bool IsInKev { get; init; }
/// <summary>Date added to KEV (if in KEV).</summary>
public DateOnly? DateAdded { get; init; }
/// <summary>Required action date (if in KEV).</summary>
public DateOnly? DueDate { get; init; }
/// <summary>Vendor/Project name.</summary>
public string? VendorProject { get; init; }
/// <summary>Product name.</summary>
public string? Product { get; init; }
/// <summary>Short description of the vulnerability.</summary>
public string? VulnerabilityName { get; init; }
/// <summary>Known ransomware campaign use.</summary>
public bool? KnownRansomwareCampaignUse { get; init; }
/// <summary>Required action.</summary>
public string? RequiredAction { get; init; }
/// <summary>Notes.</summary>
public string? Notes { get; init; }
}
/// <summary>
/// Attaches KEV signals from the CISA KEV catalog.
/// </summary>
public sealed class KevSignalAttacher : ISignalAttacher<KevLookupInput, KevSignal>
{
private readonly IKevDataSource _dataSource;
private readonly ISignalEventEmitter _eventEmitter;
private readonly TimeProvider _timeProvider;
private readonly ILogger<KevSignalAttacher> _logger;
public string SignalType => "kev";
public KevSignalAttacher(
IKevDataSource dataSource,
ISignalEventEmitter eventEmitter,
TimeProvider timeProvider,
ILogger<KevSignalAttacher> logger)
{
_dataSource = dataSource;
_eventEmitter = eventEmitter;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<SignalState<KevSignal>> AttachAsync(
KevLookupInput input,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
try
{
var kev = await _dataSource.GetKevAsync(input.CveId, ct);
if (kev is null)
{
// Not in KEV is a valid signal (not an error)
var notInKevSignal = new KevSignal
{
CveId = input.CveId,
IsInKev = false
};
_logger.LogDebug("CVE {CveId} not in KEV catalog", input.CveId);
return SignalState<KevSignal>.Success(
notInKevSignal,
now,
"cisa-kev",
TimeSpan.FromHours(12)); // KEV updates frequently
}
var signal = new KevSignal
{
CveId = input.CveId,
IsInKev = true,
DateAdded = kev.DateAdded,
DueDate = kev.DueDate,
VendorProject = kev.VendorProject,
Product = kev.Product,
VulnerabilityName = kev.VulnerabilityName,
KnownRansomwareCampaignUse = kev.KnownRansomwareCampaignUse,
RequiredAction = kev.RequiredAction,
Notes = kev.Notes
};
_logger.LogInformation(
"CVE {CveId} IS IN KEV: added={DateAdded}, due={DueDate}",
input.CveId, kev.DateAdded, kev.DueDate);
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
{
SignalType = SignalType,
CveId = input.CveId,
Status = SignalStatus.Available,
UpdatedAt = now
}, ct);
return SignalState<KevSignal>.Success(
signal,
now,
"cisa-kev",
TimeSpan.FromHours(12));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to attach KEV for CVE {CveId}", input.CveId);
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
{
SignalType = SignalType,
CveId = input.CveId,
Status = SignalStatus.Failed,
UpdatedAt = now
}, ct);
return SignalState<KevSignal>.Failed(ex.Message, now, "cisa-kev");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<SignalState<KevSignal>>> AttachBatchAsync(
IReadOnlyList<KevLookupInput> inputs,
CancellationToken ct = default)
{
var cveIds = inputs.Select(i => i.CveId).ToList();
var kevData = await _dataSource.GetKevBatchAsync(cveIds, ct);
var now = _timeProvider.GetUtcNow();
var results = new List<SignalState<KevSignal>>(inputs.Count);
foreach (var input in inputs)
{
if (kevData.TryGetValue(input.CveId, out var kev))
{
var signal = new KevSignal
{
CveId = input.CveId,
IsInKev = true,
DateAdded = kev.DateAdded,
DueDate = kev.DueDate,
VendorProject = kev.VendorProject,
Product = kev.Product,
VulnerabilityName = kev.VulnerabilityName,
KnownRansomwareCampaignUse = kev.KnownRansomwareCampaignUse,
RequiredAction = kev.RequiredAction,
Notes = kev.Notes
};
results.Add(SignalState<KevSignal>.Success(signal, now, "cisa-kev", TimeSpan.FromHours(12)));
}
else
{
var notInKevSignal = new KevSignal
{
CveId = input.CveId,
IsInKev = false
};
results.Add(SignalState<KevSignal>.Success(notInKevSignal, now, "cisa-kev", TimeSpan.FromHours(12)));
}
}
return results;
}
}
/// <summary>
/// Data source for KEV catalog.
/// </summary>
public interface IKevDataSource
{
/// <summary>
/// Gets KEV data for a CVE.
/// </summary>
Task<KevData?> GetKevAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Gets KEV data for multiple CVEs.
/// </summary>
Task<IReadOnlyDictionary<string, KevData>> GetKevBatchAsync(
IReadOnlyList<string> cveIds,
CancellationToken ct = default);
/// <summary>
/// Gets all CVE IDs currently in KEV.
/// </summary>
Task<IReadOnlySet<string>> GetAllKevCveIdsAsync(CancellationToken ct = default);
}
/// <summary>
/// Raw KEV data from the catalog.
/// </summary>
public sealed record KevData
{
public required string CveId { get; init; }
public required DateOnly DateAdded { get; init; }
public DateOnly? DueDate { get; init; }
public string? VendorProject { get; init; }
public string? Product { get; init; }
public string? VulnerabilityName { get; init; }
public bool? KnownRansomwareCampaignUse { get; init; }
public string? RequiredAction { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// SignalAttacherServiceExtensions.cs
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
// Task: DBI-004 - Create SignalAttacherServiceExtensions for DI
// Description: DI registration extensions for signal attachers
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Feedser.Core.Signals;
/// <summary>
/// Service collection extensions for signal attachers.
/// </summary>
public static class SignalAttacherServiceExtensions
{
/// <summary>
/// Adds all signal attacher services.
/// </summary>
public static IServiceCollection AddSignalAttachers(this IServiceCollection services)
{
services.AddSingleton<ISignalAttacher<EpssLookupInput, EpssSignal>, EpssSignalAttacher>();
services.AddSingleton<ISignalAttacher<KevLookupInput, KevSignal>, KevSignalAttacher>();
services.AddSingleton<ISignalEventEmitter, InMemorySignalEventEmitter>();
return services;
}
/// <summary>
/// Adds EPSS signal attacher only.
/// </summary>
public static IServiceCollection AddEpssSignalAttacher(this IServiceCollection services)
{
services.AddSingleton<ISignalAttacher<EpssLookupInput, EpssSignal>, EpssSignalAttacher>();
return services;
}
/// <summary>
/// Adds KEV signal attacher only.
/// </summary>
public static IServiceCollection AddKevSignalAttacher(this IServiceCollection services)
{
services.AddSingleton<ISignalAttacher<KevLookupInput, KevSignal>, KevSignalAttacher>();
return services;
}
/// <summary>
/// Adds signal event emitter with custom implementation.
/// </summary>
public static IServiceCollection AddSignalEventEmitter<TEmitter>(this IServiceCollection services)
where TEmitter : class, ISignalEventEmitter
{
services.AddSingleton<ISignalEventEmitter, TEmitter>();
return services;
}
}
/// <summary>
/// In-memory signal event emitter for local processing.
/// </summary>
public sealed class InMemorySignalEventEmitter : ISignalEventEmitter
{
private readonly List<Func<SignalUpdatedEvent, CancellationToken, Task>> _handlers = [];
private readonly object _lock = new();
/// <summary>
/// Subscribes a handler to signal events.
/// </summary>
public void Subscribe(Func<SignalUpdatedEvent, CancellationToken, Task> handler)
{
lock (_lock)
{
_handlers.Add(handler);
}
}
/// <inheritdoc />
public async Task EmitAsync(SignalUpdatedEvent @event, CancellationToken ct = default)
{
List<Func<SignalUpdatedEvent, CancellationToken, Task>> handlers;
lock (_lock)
{
handlers = [.. _handlers];
}
foreach (var handler in handlers)
{
await handler(@event, ct);
}
}
/// <inheritdoc />
public async Task EmitBatchAsync(IReadOnlyList<SignalUpdatedEvent> events, CancellationToken ct = default)
{
foreach (var @event in events)
{
await EmitAsync(@event, ct);
}
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
# Feedser Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0340-M | DONE | Revalidated 2026-01-07; maintainability audit for Feedser.Core. |
| AUDIT-0340-T | DONE | Revalidated 2026-01-07; test coverage audit for Feedser.Core. |
| AUDIT-0340-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |