consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
26
src/Concelier/StellaOps.Feedser.Core/AGENTS.md
Normal file
26
src/Concelier/StellaOps.Feedser.Core/AGENTS.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
258
src/Concelier/StellaOps.Feedser.Core/HunkSigExtractor.cs
Normal file
258
src/Concelier/StellaOps.Feedser.Core/HunkSigExtractor.cs
Normal 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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
154
src/Concelier/StellaOps.Feedser.Core/Signals/ISignalAttacher.cs
Normal file
154
src/Concelier/StellaOps.Feedser.Core/Signals/ISignalAttacher.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
11
src/Concelier/StellaOps.Feedser.Core/TASKS.md
Normal file
11
src/Concelier/StellaOps.Feedser.Core/TASKS.md
Normal 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. |
|
||||
Reference in New Issue
Block a user