feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies.
- Documented roles and guidelines in AGENTS.md for Scheduler module.
- Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs.
- Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics.
- Developed API endpoints for managing resolver jobs and retrieving metrics.
- Defined models for resolver job requests and responses.
- Integrated dependency injection for resolver job services.
- Implemented ImpactIndexSnapshot for persisting impact index data.
- Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring.
- Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService.
- Created dotnet-filter.sh script to handle command-line arguments for dotnet.
- Established nuget-prime project for managing package downloads.
This commit is contained in:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -15,9 +15,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
EndProject
Global
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Tests", "__Tests\StellaOps.Signals.Tests\StellaOps.Signals.Tests.csproj", "{1AB74DBC-22F8-48B8-B921-2367FFD67866}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
@@ -103,16 +105,28 @@ Global
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -29,6 +29,11 @@ public sealed class SignalsOptions
/// Air-gap configuration.
/// </summary>
public SignalsAirGapOptions AirGap { get; } = new();
/// <summary>
/// Reachability scoring configuration.
/// </summary>
public SignalsScoringOptions Scoring { get; } = new();
/// <summary>
/// Validates configured options.
@@ -39,5 +44,6 @@ public sealed class SignalsOptions
Mongo.Validate();
Storage.Validate();
AirGap.Validate();
Scoring.Validate();
}
}

View File

@@ -0,0 +1,71 @@
using System;
namespace StellaOps.Signals.Options;
/// <summary>
/// Configurable weights used by reachability scoring.
/// </summary>
public sealed class SignalsScoringOptions
{
/// <summary>
/// Confidence assigned when a path exists from entry point to target.
/// </summary>
public double ReachableConfidence { get; set; } = 0.75;
/// <summary>
/// Confidence assigned when no path exists from entry point to target.
/// </summary>
public double UnreachableConfidence { get; set; } = 0.25;
/// <summary>
/// Bonus applied when runtime evidence matches the discovered path.
/// </summary>
public double RuntimeBonus { get; set; } = 0.15;
/// <summary>
/// Maximum confidence permitted after bonuses are applied.
/// </summary>
public double MaxConfidence { get; set; } = 0.99;
/// <summary>
/// Minimum confidence permitted after penalties are applied.
/// </summary>
public double MinConfidence { get; set; } = 0.05;
public void Validate()
{
EnsurePercent(nameof(ReachableConfidence), ReachableConfidence);
EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence);
EnsurePercent(nameof(RuntimeBonus), RuntimeBonus);
EnsurePercent(nameof(MaxConfidence), MaxConfidence);
EnsurePercent(nameof(MinConfidence), MinConfidence);
if (MinConfidence > UnreachableConfidence)
{
throw new ArgumentException("MinConfidence must be less than or equal to UnreachableConfidence.");
}
if (UnreachableConfidence > ReachableConfidence)
{
throw new ArgumentException("UnreachableConfidence must be less than or equal to ReachableConfidence.");
}
if (ReachableConfidence > MaxConfidence)
{
throw new ArgumentException("ReachableConfidence must be less than or equal to MaxConfidence.");
}
if (MinConfidence >= MaxConfidence)
{
throw new ArgumentException("MinConfidence must be less than MaxConfidence.");
}
}
private static void EnsurePercent(string name, double value)
{
if (double.IsNaN(value) || value < 0.0 || value > 1.0)
{
throw new ArgumentOutOfRangeException(name, value, "Value must be between 0 and 1.");
}
}
}

View File

@@ -4,33 +4,32 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
public sealed class ReachabilityScoringService : IReachabilityScoringService
{
private const double ReachableConfidence = 0.75;
private const double UnreachableConfidence = 0.25;
private const double RuntimeBonus = 0.15;
private const double MaxConfidence = 0.99;
private const double MinConfidence = 0.05;
private readonly ICallgraphRepository callgraphRepository;
private readonly IReachabilityFactRepository factRepository;
private readonly TimeProvider timeProvider;
private readonly SignalsScoringOptions scoringOptions;
private readonly ILogger<ReachabilityScoringService> logger;
public ReachabilityScoringService(
ICallgraphRepository callgraphRepository,
IReachabilityFactRepository factRepository,
TimeProvider timeProvider,
IOptions<SignalsOptions> options,
ILogger<ReachabilityScoringService> logger)
{
this.callgraphRepository = callgraphRepository ?? throw new ArgumentNullException(nameof(callgraphRepository));
this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.scoringOptions = options?.Value?.Scoring ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -89,16 +88,16 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
{
var path = FindPath(entryPoints, target, graph.Adjacency);
var reachable = path is not null;
var confidence = reachable ? ReachableConfidence : UnreachableConfidence;
var confidence = reachable ? scoringOptions.ReachableConfidence : scoringOptions.UnreachableConfidence;
var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true)
.ToList();
if (runtimeEvidence.Count > 0)
{
confidence = Math.Min(MaxConfidence, confidence + RuntimeBonus);
confidence = Math.Min(scoringOptions.MaxConfidence, confidence + scoringOptions.RuntimeBonus);
}
confidence = Math.Clamp(confidence, MinConfidence, MaxConfidence);
confidence = Math.Clamp(confidence, scoringOptions.MinConfidence, scoringOptions.MaxConfidence);
states.Add(new ReachabilityStateDocument
{

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
@@ -13,16 +14,19 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
{
private readonly IReachabilityFactRepository factRepository;
private readonly TimeProvider timeProvider;
private readonly IReachabilityScoringService scoringService;
private readonly ILogger<RuntimeFactsIngestionService> logger;
public RuntimeFactsIngestionService(
IReachabilityFactRepository factRepository,
TimeProvider timeProvider,
IReachabilityScoringService scoringService,
ILogger<RuntimeFactsIngestionService> logger)
{
this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
this.logger = logger ?? NullLogger<RuntimeFactsIngestionService>.Instance;
}
public async Task<RuntimeFactsIngestResponse> IngestAsync(RuntimeFactsIngestRequest request, CancellationToken cancellationToken)
@@ -47,9 +51,15 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
var aggregated = AggregateRuntimeFacts(request.Events);
document.RuntimeFacts = MergeRuntimeFacts(document.RuntimeFacts, aggregated);
document.Metadata = MergeMetadata(document.Metadata, request.Metadata);
document.Metadata ??= new Dictionary<string, string?>(StringComparer.Ordinal);
document.Metadata.TryAdd("provenance.source", request.Metadata?.TryGetValue("source", out var source) == true ? source : "runtime");
document.Metadata["provenance.ingestedAt"] = document.ComputedAt.ToString("O");
document.Metadata["provenance.callgraphId"] = request.CallgraphId;
var persisted = await factRepository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await RecomputeReachabilityAsync(persisted, aggregated, request, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Stored {RuntimeFactCount} runtime fact(s) for subject {SubjectKey} (callgraph={CallgraphId}).",
persisted.RuntimeFacts?.Count ?? 0,
@@ -244,6 +254,67 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
.ToList();
}
private async Task RecomputeReachabilityAsync(
ReachabilityFactDocument persisted,
List<RuntimeFactDocument> aggregatedRuntimeFacts,
RuntimeFactsIngestRequest request,
CancellationToken cancellationToken)
{
var targets = new HashSet<string>(StringComparer.Ordinal);
if (persisted.States is { Count: > 0 })
{
foreach (var state in persisted.States)
{
if (!string.IsNullOrWhiteSpace(state.Target))
{
targets.Add(state.Target.Trim());
}
}
}
foreach (var fact in aggregatedRuntimeFacts)
{
if (!string.IsNullOrWhiteSpace(fact.SymbolId))
{
targets.Add(fact.SymbolId.Trim());
}
}
var runtimeHits = aggregatedRuntimeFacts
.Select(f => f.SymbolId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (targets.Count == 0)
{
return;
}
var requestMetadata = MergeMetadata(persisted.Metadata, request.Metadata);
var recomputeRequest = new ReachabilityRecomputeRequest
{
CallgraphId = request.CallgraphId,
Subject = request.Subject,
EntryPoints = persisted.EntryPoints ?? new List<string>(),
Targets = targets.ToList(),
RuntimeHits = runtimeHits,
Metadata = requestMetadata
};
try
{
await scoringService.RecomputeAsync(recomputeRequest, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to recompute reachability after runtime ingestion for subject {SubjectKey}.", persisted.SubjectKey);
throw;
}
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();

View File

@@ -0,0 +1,111 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
public class ReachabilityScoringServiceTests
{
[Fact]
public async Task RecomputeAsync_UsesConfiguredWeights()
{
var callgraph = new CallgraphDocument
{
Id = "cg-1",
Language = "java",
Component = "demo",
Version = "1.0.0",
Nodes = new List<CallgraphNode>
{
new("main", "Main", "method", null, null, null),
new("svc", "Svc", "method", null, null, null),
new("target", "Target", "method", null, null, null)
},
Edges = new List<CallgraphEdge>
{
new("main", "svc", "call"),
new("svc", "target", "call")
}
};
var callgraphRepository = new InMemoryCallgraphRepository(callgraph);
var factRepository = new InMemoryReachabilityFactRepository();
var options = new SignalsOptions();
options.Scoring.ReachableConfidence = 0.8;
options.Scoring.UnreachableConfidence = 0.3;
options.Scoring.RuntimeBonus = 0.1;
options.Scoring.MaxConfidence = 0.95;
options.Scoring.MinConfidence = 0.1;
var service = new ReachabilityScoringService(
callgraphRepository,
factRepository,
TimeProvider.System,
Options.Create(options),
NullLogger<ReachabilityScoringService>.Instance);
var request = new ReachabilityRecomputeRequest
{
CallgraphId = callgraph.Id,
Subject = new ReachabilitySubject { Component = "demo", Version = "1.0.0" },
EntryPoints = new List<string> { "main" },
Targets = new List<string> { "target" },
RuntimeHits = new List<string> { "svc", "target" }
};
var fact = await service.RecomputeAsync(request, CancellationToken.None);
Assert.Equal(callgraph.Id, fact.CallgraphId);
Assert.Single(fact.States);
var state = fact.States[0];
Assert.True(state.Reachable);
Assert.Equal("target", state.Target);
Assert.Equal(new[] { "main", "svc", "target" }, state.Path);
Assert.Equal(0.9, state.Confidence, 2); // 0.8 + 0.1 runtime bonus
Assert.Contains("svc", state.Evidence.RuntimeHits);
Assert.Contains("target", state.Evidence.RuntimeHits);
}
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
{
private readonly CallgraphDocument document;
public InMemoryCallgraphRepository(CallgraphDocument document)
{
this.document = document;
}
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(document.Id == id ? document : null);
}
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
// Not needed for this test
return Task.FromResult(document);
}
}
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
{
public ReachabilityFactDocument? Last;
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(Last);
}
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
{
Last = document;
return Task.FromResult(document);
}
}
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
public class RuntimeFactsIngestionServiceTests
{
[Fact]
public async Task IngestAsync_AggregatesHits_AndRecomputesReachability()
{
var factRepository = new InMemoryReachabilityFactRepository();
var scoringService = new RecordingScoringService();
var service = new RuntimeFactsIngestionService(
factRepository,
TimeProvider.System,
scoringService,
NullLogger<RuntimeFactsIngestionService>.Instance);
var request = new RuntimeFactsIngestRequest
{
Subject = new ReachabilitySubject { Component = "web", Version = "2.1.0" },
CallgraphId = "cg-123",
Metadata = new Dictionary<string, string?> { { "source", "runtime" } },
Events = new List<RuntimeFactEvent>
{
new() { SymbolId = "svc.foo", HitCount = 2, Metadata = new Dictionary<string, string?> { { "pid", "12" } } },
new() { SymbolId = "svc.bar", HitCount = 1 },
new() { SymbolId = "svc.foo", HitCount = 3 }
}
};
var response = await service.IngestAsync(request, CancellationToken.None);
Assert.Equal("web|2.1.0", response.SubjectKey);
Assert.Equal("cg-123", response.CallgraphId);
var persisted = factRepository.Last ?? throw new Xunit.Sdk.XunitException("Fact not persisted");
Assert.Equal(2, persisted.RuntimeFacts?.Count);
var foo = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.foo");
Assert.Equal(5, foo?.HitCount);
var bar = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.bar");
Assert.Equal(1, bar?.HitCount);
var recorded = scoringService.LastRequest ?? throw new Xunit.Sdk.XunitException("Recompute not triggered");
Assert.Equal("cg-123", recorded.CallgraphId);
Assert.Contains("svc.foo", recorded.Targets);
Assert.Contains("svc.bar", recorded.RuntimeHits!);
Assert.Equal("runtime", recorded.Metadata?["source"]);
Assert.Equal("runtime", persisted.Metadata?["provenance.source"]);
Assert.Equal("cg-123", persisted.Metadata?["provenance.callgraphId"]);
Assert.NotNull(persisted.Metadata?["provenance.ingestedAt"]);
}
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
{
public ReachabilityFactDocument? Last { get; private set; }
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(Last);
}
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
{
Last = document;
return Task.FromResult(document);
}
}
private sealed class RecordingScoringService : IReachabilityScoringService
{
public ReachabilityRecomputeRequest? LastRequest { get; private set; }
public Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new ReachabilityFactDocument
{
CallgraphId = request.CallgraphId,
Subject = request.Subject,
SubjectKey = request.Subject?.ToSubjectKey() ?? string.Empty,
EntryPoints = request.EntryPoints,
States = new List<ReachabilityStateDocument>(),
RuntimeFacts = new List<RuntimeFactDocument>()
});
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>
</Project>