feat: Add Bun language analyzer and related functionality

- Implemented BunPackageNormalizer to deduplicate packages by name and version.
- Created BunProjectDiscoverer to identify Bun project roots in the filesystem.
- Added project files for the Bun analyzer including manifest and project configuration.
- Developed comprehensive tests for Bun language analyzer covering various scenarios.
- Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces.
- Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -48,6 +48,7 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Aoc;
using StellaOps.Concelier.WebService.Deprecation;
using StellaOps.Aoc.AspNetCore.Routing;
using StellaOps.Aoc.AspNetCore.Results;
using StellaOps.Concelier.WebService.Contracts;
@@ -229,6 +230,30 @@ builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddSingleton<ILinksetCacheTelemetry>(sp => sp.GetRequiredService<LinksetCacheTelemetry>());
// Register read-through cache service for LNM linksets (CONCELIER-AIAI-31-002)
// When Postgres is enabled, uses it as cache backing; otherwise builds from observations directly
builder.Services.AddSingleton<ReadThroughLinksetCacheService>(sp =>
{
var observations = sp.GetRequiredService<IAdvisoryObservationLookup>();
var telemetry = sp.GetRequiredService<ILinksetCacheTelemetry>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
// Get Postgres cache if available (registered by AddConcelierPostgresStorage)
var cacheLookup = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetLookup;
var cacheSink = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetSink;
return new ReadThroughLinksetCacheService(
observations,
telemetry,
timeProvider,
cacheLookup,
cacheSink);
});
// Use read-through cache as the primary linkset lookup
builder.Services.AddSingleton<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<ReadThroughLinksetCacheService>());
builder.Services.AddAdvisoryRawServices();
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
@@ -462,6 +487,9 @@ if (authorityConfigured)
app.UseAuthorization();
}
// Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001)
app.UseDeprecationHeaders();
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
@@ -848,6 +876,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
[FromQuery(Name = "source")] string? source,
[FromServices] IAdvisoryLinksetQueryService queryService,
[FromServices] IAdvisoryObservationQueryService observationQueryService,
[FromServices] IAdvisoryLinksetStore linksetStore,
[FromServices] LinksetCacheTelemetry telemetry,
CancellationToken cancellationToken,
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
@@ -872,24 +901,57 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
}
var stopwatch = Stopwatch.StartNew();
var advisoryIds = new[] { advisoryId.Trim() };
var normalizedAdvisoryId = advisoryId.Trim();
var advisoryIds = new[] { normalizedAdvisoryId };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
// Phase 1: Try cache lookup first (CONCELIER-AIAI-31-002)
var cached = await linksetStore
.FindByTenantAsync(tenant!, advisoryIds, sources, cursor: null, limit: 1, cancellationToken)
.ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
AdvisoryLinkset linkset;
bool fromCache = false;
if (cached.Count > 0)
{
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
// Cache hit
linkset = cached[0];
fromCache = true;
telemetry.RecordHit(tenant, linkset.Source);
}
else
{
// Cache miss - rebuild from query service
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
.ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
{
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
}
linkset = result.Linksets[0];
// Write to cache
try
{
await linksetStore.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
telemetry.RecordWrite(tenant, linkset.Source);
}
catch (Exception ex)
{
// Log but don't fail request on cache write errors
context.RequestServices.GetRequiredService<ILogger<Program>>()
.LogWarning(ex, "Failed to write linkset to cache for {AdvisoryId}", normalizedAdvisoryId);
}
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
}
var linkset = result.Linksets[0];
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary, cached: fromCache);
return Results.Ok(response);
}).WithName("GetLnmLinkset");
@@ -2553,7 +2615,8 @@ LnmLinksetResponse ToLnmResponse(
bool includeTimeline,
bool includeObservations,
LinksetObservationSummary summary,
DataFreshnessInfo? freshness = null)
DataFreshnessInfo? freshness = null,
bool cached = false)
{
var normalized = linkset.Normalized;
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
@@ -2606,7 +2669,7 @@ LnmLinksetResponse ToLnmResponse(
conflicts,
timeline,
normalizedDto,
Cached: false,
Cached: cached,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
Freshness: freshness);