Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET.
- Added `all-visibility-levels.json` to validate method visibility levels in .NET.
- Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application.
- Included `go-gin-api.json` for a Go Gin API application structure.
- Added `java-spring-boot.json` for the Spring PetClinic application in Java.
- Introduced `legacy-no-schema.json` for legacy application structure without schema.
- Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -289,5 +289,19 @@ public class ReachabilityScoringServiceTests
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken)
{
var query = Stored.AsEnumerable();
if (band.HasValue)
query = query.Where(x => x.Band == band.Value);
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(Stored.FirstOrDefault(x => x.Id == id));
}
}
}

View File

@@ -475,6 +475,20 @@ public class UnknownsDecayServiceTests
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken)
{
var query = _stored.AsEnumerable();
if (band.HasValue)
query = query.Where(x => x.Band == band.Value);
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.FirstOrDefault(x => x.Id == id));
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
@@ -492,6 +506,13 @@ public class UnknownsDecayServiceTests
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
public Task UpsertAsync(DeploymentRef deployment, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<DeploymentSummary?> GetSummaryAsync(string purl, CancellationToken cancellationToken) =>
Task.FromResult<DeploymentSummary?>(null);
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
@@ -508,6 +529,15 @@ public class UnknownsDecayServiceTests
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
public Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<GraphMetrics> metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<string>> GetStaleCallgraphsAsync(TimeSpan maxAge, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken) => Task.CompletedTask;
}
#endregion

View File

@@ -103,5 +103,19 @@ public class UnknownsIngestionServiceTests
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken)
{
var query = Stored.AsEnumerable();
if (band.HasValue)
query = query.Where(x => x.Band == band.Value);
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(Stored.FirstOrDefault(x => x.Id == id));
}
}
}

View File

@@ -0,0 +1,759 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
/// <summary>
/// Integration tests for the unknowns scoring system.
/// Tests end-to-end flow: ingest → score → persist → query.
/// </summary>
public sealed class UnknownsScoringIntegrationTests
{
private readonly MockTimeProvider _timeProvider;
private readonly FullInMemoryUnknownsRepository _unknownsRepo;
private readonly InMemoryDeploymentRefsRepository _deploymentRefs;
private readonly InMemoryGraphMetricsRepository _graphMetrics;
private readonly UnknownsScoringOptions _defaultOptions;
public UnknownsScoringIntegrationTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new FullInMemoryUnknownsRepository();
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_defaultOptions = new UnknownsScoringOptions();
}
private UnknownsScoringService CreateService(UnknownsScoringOptions? options = null)
{
return new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(options ?? _defaultOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
}
#region End-to-End Flow Tests
[Fact]
public async Task EndToEnd_IngestScoreAndQueryByBand()
{
// Arrange: Create unknowns with varying factors
var now = _timeProvider.GetUtcNow();
var subjectKey = "test|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
// High-priority unknown (should be HOT)
new()
{
Id = "unknown-hot",
SubjectKey = subjectKey,
Purl = "pkg:npm/critical-pkg@1.0.0",
SymbolId = "sym-hot",
CallgraphId = "cg-1",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20)
},
// Medium-priority unknown (should be WARM)
new()
{
Id = "unknown-warm",
SubjectKey = subjectKey,
Purl = "pkg:npm/moderate-pkg@2.0.0",
SymbolId = "sym-warm",
CallgraphId = "cg-1",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true
},
CreatedAt = now.AddDays(-10)
},
// Low-priority unknown (should be COLD)
new()
{
Id = "unknown-cold",
SubjectKey = subjectKey,
Purl = "pkg:npm/low-pkg@3.0.0",
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
}
};
// Set up deployment refs for popularity factor
_deploymentRefs.SetDeploymentCount("pkg:npm/critical-pkg@1.0.0", 100);
_deploymentRefs.SetDeploymentCount("pkg:npm/moderate-pkg@2.0.0", 50);
_deploymentRefs.SetDeploymentCount("pkg:npm/low-pkg@3.0.0", 1);
// Set up graph metrics for centrality factor
_graphMetrics.SetMetrics("sym-hot", "cg-1", new GraphMetrics { NodeId = "sym-hot", CallgraphId = "cg-1", Degree = 20, Betweenness = 800.0 });
_graphMetrics.SetMetrics("sym-warm", "cg-1", new GraphMetrics { NodeId = "sym-warm", CallgraphId = "cg-1", Degree = 10, Betweenness = 300.0 });
// Act 1: Ingest unknowns
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act 2: Score all unknowns
var service = CreateService();
var result = await service.RecomputeAsync(subjectKey, CancellationToken.None);
// Assert: Verify scoring result
result.TotalUnknowns.Should().Be(3);
result.SubjectKey.Should().Be(subjectKey);
// Act 3: Query by band
var hotItems = await _unknownsRepo.QueryAsync(UnknownsBand.Hot, 10, 0, CancellationToken.None);
var warmItems = await _unknownsRepo.QueryAsync(UnknownsBand.Warm, 10, 0, CancellationToken.None);
var coldItems = await _unknownsRepo.QueryAsync(UnknownsBand.Cold, 10, 0, CancellationToken.None);
// Assert: Verify band distribution
hotItems.Should().Contain(u => u.Id == "unknown-hot");
warmItems.Should().Contain(u => u.Id == "unknown-warm");
coldItems.Should().Contain(u => u.Id == "unknown-cold");
// Verify scores are persisted
var hotUnknown = await _unknownsRepo.GetByIdAsync("unknown-hot", CancellationToken.None);
hotUnknown.Should().NotBeNull();
hotUnknown!.Score.Should().BeGreaterThanOrEqualTo(_defaultOptions.HotThreshold);
hotUnknown.NormalizationTrace.Should().NotBeNull();
}
[Fact]
public async Task EndToEnd_RecomputePreservesExistingData()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var subjectKey = "preserve|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "preserve-1",
SubjectKey = subjectKey,
Purl = "pkg:npm/preserve@1.0.0",
Reason = "Missing symbol resolution",
EdgeFrom = "caller",
EdgeTo = "target",
LastAnalyzedAt = now.AddDays(-5),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
}
};
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act: Score
var service = CreateService();
await service.RecomputeAsync(subjectKey, CancellationToken.None);
// Assert: Original data preserved
var retrieved = await _unknownsRepo.GetByIdAsync("preserve-1", CancellationToken.None);
retrieved.Should().NotBeNull();
retrieved!.Reason.Should().Be("Missing symbol resolution");
retrieved.EdgeFrom.Should().Be("caller");
retrieved.EdgeTo.Should().Be("target");
retrieved.SubjectKey.Should().Be(subjectKey);
}
[Fact]
public async Task EndToEnd_MultipleSubjectsIndependent()
{
// Arrange: Create unknowns in two different subjects
var now = _timeProvider.GetUtcNow();
var subject1Unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "s1-unknown",
SubjectKey = "subject1|1.0.0",
Purl = "pkg:npm/s1pkg@1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true },
CreatedAt = now.AddDays(-20)
}
};
var subject2Unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "s2-unknown",
SubjectKey = "subject2|2.0.0",
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
}
};
await _unknownsRepo.UpsertAsync("subject1|1.0.0", subject1Unknowns, CancellationToken.None);
await _unknownsRepo.UpsertAsync("subject2|2.0.0", subject2Unknowns, CancellationToken.None);
// Act: Score each subject independently
var service = CreateService();
var result1 = await service.RecomputeAsync("subject1|1.0.0", CancellationToken.None);
var result2 = await service.RecomputeAsync("subject2|2.0.0", CancellationToken.None);
// Assert: Each subject scored independently
result1.SubjectKey.Should().Be("subject1|1.0.0");
result1.TotalUnknowns.Should().Be(1);
result2.SubjectKey.Should().Be("subject2|2.0.0");
result2.TotalUnknowns.Should().Be(1);
// Verify different bands
var s1 = await _unknownsRepo.GetByIdAsync("s1-unknown", CancellationToken.None);
var s2 = await _unknownsRepo.GetByIdAsync("s2-unknown", CancellationToken.None);
s1!.Score.Should().BeGreaterThan(s2!.Score, "S1 has more uncertainty flags");
}
#endregion
#region Rescan Scheduling Tests
[Fact]
public async Task Rescan_GetDueForRescan_ReturnsCorrectBandItems()
{
// Arrange: Create unknowns with different bands
var now = _timeProvider.GetUtcNow();
var subjectKey = "rescan|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "hot-rescan",
SubjectKey = subjectKey,
Band = UnknownsBand.Hot,
NextScheduledRescan = now.AddMinutes(-5), // Due
CreatedAt = now.AddDays(-1)
},
new()
{
Id = "warm-rescan",
SubjectKey = subjectKey,
Band = UnknownsBand.Warm,
NextScheduledRescan = now.AddHours(12), // Not due
CreatedAt = now.AddDays(-1)
},
new()
{
Id = "cold-rescan",
SubjectKey = subjectKey,
Band = UnknownsBand.Cold,
NextScheduledRescan = now.AddDays(7), // Not due
CreatedAt = now.AddDays(-1)
}
};
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act: Query due for rescan
var hotDue = await _unknownsRepo.GetDueForRescanAsync(UnknownsBand.Hot, 10, CancellationToken.None);
var warmDue = await _unknownsRepo.GetDueForRescanAsync(UnknownsBand.Warm, 10, CancellationToken.None);
// Assert
hotDue.Should().Contain(u => u.Id == "hot-rescan");
warmDue.Should().NotContain(u => u.Id == "warm-rescan", "WARM item not yet due");
}
[Fact]
public async Task Rescan_NextScheduledRescan_SetByBand()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var subjectKey = "schedule|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "schedule-hot",
SubjectKey = subjectKey,
Purl = "pkg:npm/schedule@1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20)
},
new()
{
Id = "schedule-cold",
SubjectKey = subjectKey,
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
}
};
_deploymentRefs.SetDeploymentCount("pkg:npm/schedule@1.0.0", 100);
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act
var service = CreateService();
await service.RecomputeAsync(subjectKey, CancellationToken.None);
// Assert
var hot = await _unknownsRepo.GetByIdAsync("schedule-hot", CancellationToken.None);
var cold = await _unknownsRepo.GetByIdAsync("schedule-cold", CancellationToken.None);
if (hot!.Band == UnknownsBand.Hot)
{
hot.NextScheduledRescan.Should().Be(now.AddMinutes(_defaultOptions.HotRescanMinutes));
}
cold!.NextScheduledRescan.Should().Be(now.AddDays(_defaultOptions.ColdRescanDays));
}
#endregion
#region Query and Pagination Tests
[Fact]
public async Task Query_PaginationWorks()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var subjectKey = "pagination|1.0.0";
var unknowns = Enumerable.Range(1, 20)
.Select(i => new UnknownSymbolDocument
{
Id = $"page-{i:D2}",
SubjectKey = subjectKey,
Band = UnknownsBand.Warm,
CreatedAt = now.AddDays(-i)
})
.ToList();
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act: Query with pagination
var page1 = await _unknownsRepo.QueryAsync(UnknownsBand.Warm, limit: 5, offset: 0, CancellationToken.None);
var page2 = await _unknownsRepo.QueryAsync(UnknownsBand.Warm, limit: 5, offset: 5, CancellationToken.None);
// Assert
page1.Should().HaveCount(5);
page2.Should().HaveCount(5);
page1.Select(u => u.Id).Should().NotIntersectWith(page2.Select(u => u.Id));
}
[Fact]
public async Task Query_FilterByBandReturnsOnlyMatchingItems()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var unknowns = new List<UnknownSymbolDocument>
{
new() { Id = "hot-1", SubjectKey = "filter|1.0.0", Band = UnknownsBand.Hot, CreatedAt = now },
new() { Id = "hot-2", SubjectKey = "filter|1.0.0", Band = UnknownsBand.Hot, CreatedAt = now },
new() { Id = "warm-1", SubjectKey = "filter|1.0.0", Band = UnknownsBand.Warm, CreatedAt = now },
new() { Id = "cold-1", SubjectKey = "filter|1.0.0", Band = UnknownsBand.Cold, CreatedAt = now }
};
await _unknownsRepo.UpsertAsync("filter|1.0.0", unknowns, CancellationToken.None);
// Act
var hotOnly = await _unknownsRepo.QueryAsync(UnknownsBand.Hot, 10, 0, CancellationToken.None);
var warmOnly = await _unknownsRepo.QueryAsync(UnknownsBand.Warm, 10, 0, CancellationToken.None);
var all = await _unknownsRepo.QueryAsync(null, 10, 0, CancellationToken.None);
// Assert
hotOnly.Should().HaveCount(2);
hotOnly.Should().AllSatisfy(u => u.Band.Should().Be(UnknownsBand.Hot));
warmOnly.Should().HaveCount(1);
warmOnly.Single().Band.Should().Be(UnknownsBand.Warm);
all.Should().HaveCount(4);
}
#endregion
#region Explain / Normalization Trace Tests
[Fact]
public async Task Explain_NormalizationTraceContainsAllFactors()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var subjectKey = "explain|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "explain-1",
SubjectKey = subjectKey,
Purl = "pkg:npm/explain@1.0.0",
SymbolId = "sym-explain",
CallgraphId = "cg-explain",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true
},
CreatedAt = now.AddDays(-10)
}
};
_deploymentRefs.SetDeploymentCount("pkg:npm/explain@1.0.0", 75);
_graphMetrics.SetMetrics("sym-explain", "cg-explain", new GraphMetrics { NodeId = "sym-explain", CallgraphId = "cg-explain", Degree = 15, Betweenness = 450.0 });
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act
var service = CreateService();
await service.RecomputeAsync(subjectKey, CancellationToken.None);
// Assert: Get by ID and verify trace
var explained = await _unknownsRepo.GetByIdAsync("explain-1", CancellationToken.None);
explained.Should().NotBeNull();
var trace = explained!.NormalizationTrace;
trace.Should().NotBeNull();
// Verify all factors are traced
trace!.Weights.Should().ContainKey("wP");
trace.Weights.Should().ContainKey("wE");
trace.Weights.Should().ContainKey("wU");
trace.Weights.Should().ContainKey("wC");
trace.Weights.Should().ContainKey("wS");
// Verify popularity trace
trace.RawPopularity.Should().Be(75);
trace.NormalizedPopularity.Should().BeInRange(0.0, 1.0);
trace.PopularityFormula.Should().Contain("75");
// Verify uncertainty trace
trace.ActiveFlags.Should().Contain("NoProvenanceAnchor");
trace.ActiveFlags.Should().Contain("VersionRange");
trace.NormalizedUncertainty.Should().BeInRange(0.0, 1.0);
// Verify centrality trace
trace.RawCentrality.Should().Be(450.0);
trace.NormalizedCentrality.Should().BeInRange(0.0, 1.0);
// Verify staleness trace
trace.RawStaleness.Should().Be(7);
trace.NormalizedStaleness.Should().BeInRange(0.0, 1.0);
// Verify final score
trace.FinalScore.Should().Be(explained.Score);
trace.AssignedBand.Should().Be(explained.Band.ToString());
}
[Fact]
public async Task Explain_TraceEnablesReplay()
{
// Arrange: Score an unknown
var now = _timeProvider.GetUtcNow();
var subjectKey = "replay|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "replay-1",
SubjectKey = subjectKey,
Purl = "pkg:npm/replay@1.0.0",
LastAnalyzedAt = now.AddDays(-10),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-15)
}
};
_deploymentRefs.SetDeploymentCount("pkg:npm/replay@1.0.0", 30);
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
var service = CreateService();
await service.RecomputeAsync(subjectKey, CancellationToken.None);
// Act: Retrieve and verify we can replay the score from trace
var scored = await _unknownsRepo.GetByIdAsync("replay-1", CancellationToken.None);
var trace = scored!.NormalizationTrace!;
// Replay: weighted sum of normalized factors
var replayedScore =
trace.Weights["wP"] * trace.NormalizedPopularity +
trace.Weights["wE"] * trace.NormalizedExploitPotential +
trace.Weights["wU"] * trace.NormalizedUncertainty +
trace.Weights["wC"] * trace.NormalizedCentrality +
trace.Weights["wS"] * trace.NormalizedStaleness;
// Assert: Replayed score matches
replayedScore.Should().BeApproximately(trace.FinalScore, 0.001);
replayedScore.Should().BeApproximately(scored.Score, 0.001);
}
#endregion
#region Determinism Tests
[Fact]
public async Task Determinism_SameInputsProduceSameScores()
{
// Arrange
var now = _timeProvider.GetUtcNow();
// Create two identical unknowns in different subjects
var unknown1 = new UnknownSymbolDocument
{
Id = "det-1",
SubjectKey = "det-subject1|1.0.0",
Purl = "pkg:npm/determinism@1.0.0",
SymbolId = "sym-det",
CallgraphId = "cg-det",
LastAnalyzedAt = now.AddDays(-5),
Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true },
CreatedAt = now.AddDays(-10)
};
var unknown2 = new UnknownSymbolDocument
{
Id = "det-2",
SubjectKey = "det-subject2|1.0.0",
Purl = "pkg:npm/determinism@1.0.0",
SymbolId = "sym-det",
CallgraphId = "cg-det",
LastAnalyzedAt = now.AddDays(-5),
Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true },
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/determinism@1.0.0", 42);
_graphMetrics.SetMetrics("sym-det", "cg-det", new GraphMetrics { NodeId = "sym-det", CallgraphId = "cg-det", Degree = 8, Betweenness = 200.0 });
await _unknownsRepo.UpsertAsync("det-subject1|1.0.0", new[] { unknown1 }, CancellationToken.None);
await _unknownsRepo.UpsertAsync("det-subject2|1.0.0", new[] { unknown2 }, CancellationToken.None);
// Act
var service = CreateService();
await service.RecomputeAsync("det-subject1|1.0.0", CancellationToken.None);
await service.RecomputeAsync("det-subject2|1.0.0", CancellationToken.None);
// Assert
var scored1 = await _unknownsRepo.GetByIdAsync("det-1", CancellationToken.None);
var scored2 = await _unknownsRepo.GetByIdAsync("det-2", CancellationToken.None);
scored1!.Score.Should().Be(scored2!.Score);
scored1.Band.Should().Be(scored2.Band);
scored1.PopularityScore.Should().Be(scored2.PopularityScore);
scored1.UncertaintyScore.Should().Be(scored2.UncertaintyScore);
scored1.CentralityScore.Should().Be(scored2.CentralityScore);
scored1.StalenessScore.Should().Be(scored2.StalenessScore);
}
[Fact]
public async Task Determinism_ConsecutiveRecomputesProduceSameResults()
{
// Arrange
var now = _timeProvider.GetUtcNow();
var subjectKey = "consecutive|1.0.0";
var unknowns = new List<UnknownSymbolDocument>
{
new()
{
Id = "consec-1",
SubjectKey = subjectKey,
Purl = "pkg:npm/consecutive@1.0.0",
LastAnalyzedAt = now.AddDays(-3),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-5)
}
};
_deploymentRefs.SetDeploymentCount("pkg:npm/consecutive@1.0.0", 25);
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
// Act: Score twice
var service = CreateService();
var result1 = await service.RecomputeAsync(subjectKey, CancellationToken.None);
var scored1 = await _unknownsRepo.GetByIdAsync("consec-1", CancellationToken.None);
var score1 = scored1!.Score;
var result2 = await service.RecomputeAsync(subjectKey, CancellationToken.None);
var scored2 = await _unknownsRepo.GetByIdAsync("consec-1", CancellationToken.None);
var score2 = scored2!.Score;
// Assert
score1.Should().Be(score2);
result1.HotCount.Should().Be(result2.HotCount);
result1.WarmCount.Should().Be(result2.WarmCount);
result1.ColdCount.Should().Be(result2.ColdCount);
}
#endregion
#region Test Infrastructure
private sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public MockTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class FullInMemoryUnknownsRepository : IUnknownsRepository
{
private readonly List<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = _stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
_stored[existing] = item;
else
_stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
UnknownsBand band,
int limit,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored
.Where(x => x.Band == band && (x.NextScheduledRescan == null || x.NextScheduledRescan <= now))
.Take(limit)
.ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
UnknownsBand? band,
int limit,
int offset,
CancellationToken cancellationToken)
{
var query = _stored.AsEnumerable();
if (band.HasValue)
{
query = query.Where(x => x.Band == band.Value);
}
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.FirstOrDefault(x => x.Id == id));
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
public Task UpsertAsync(DeploymentRef deployment, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<DeploymentSummary?> GetSummaryAsync(string purl, CancellationToken cancellationToken) =>
Task.FromResult<DeploymentSummary?>(null);
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken)
{
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
public Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<GraphMetrics> metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<string>> GetStaleCallgraphsAsync(TimeSpan maxAge, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken) => Task.CompletedTask;
}
#endregion
}

View File

@@ -297,7 +297,7 @@ public class UnknownsScoringServiceTests
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
_graphMetrics.SetMetrics("sym-1", "cg-1", new GraphMetrics(Degree: 10, Betweenness: 500.0));
_graphMetrics.SetMetrics("sym-1", "cg-1", new GraphMetrics { NodeId = "sym-1", CallgraphId = "cg-1", Degree = 10, Betweenness = 500.0 });
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
@@ -495,6 +495,20 @@ public class UnknownsScoringServiceTests
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken)
{
var query = _stored.AsEnumerable();
if (band.HasValue)
query = query.Where(x => x.Band == band.Value);
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.FirstOrDefault(x => x.Id == id));
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
@@ -512,6 +526,13 @@ public class UnknownsScoringServiceTests
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
public Task UpsertAsync(DeploymentRef deployment, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<DeploymentSummary?> GetSummaryAsync(string purl, CancellationToken cancellationToken) =>
Task.FromResult<DeploymentSummary?>(null);
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
@@ -528,6 +549,15 @@ public class UnknownsScoringServiceTests
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
public Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<GraphMetrics> metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<string>> GetStaleCallgraphsAsync(TimeSpan maxAge, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken) => Task.CompletedTask;
}
#endregion