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
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user