consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
using Blake3;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic BLAKE3 hashing helpers for Symbols artifacts and manifests.
|
||||
/// </summary>
|
||||
public static class SymbolHashing
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a BLAKE3 digest string with algorithm prefix.
|
||||
/// </summary>
|
||||
public static string ComputeHash(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
using var hasher = Hasher.New();
|
||||
hasher.Update(bytes);
|
||||
return $"blake3:{Convert.ToHexStringLower(hasher.Finalize().AsSpan())}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic manifest identifier from manifest identity inputs.
|
||||
/// </summary>
|
||||
public static string ComputeManifestId(
|
||||
string debugId,
|
||||
string tenantId,
|
||||
IReadOnlyList<SymbolEntry> symbols)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(debugId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
|
||||
var builder = new StringBuilder(capacity: 256 + (symbols.Count * 96));
|
||||
builder.Append("debug=").Append(debugId.Trim()).Append('\n');
|
||||
builder.Append("tenant=").Append(tenantId.Trim()).Append('\n');
|
||||
|
||||
foreach (var line in symbols
|
||||
.Select(SerializeSymbolEntry)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(line).Append('\n');
|
||||
}
|
||||
|
||||
return ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts lowercase hexadecimal digest bytes from a prefixed hash value.
|
||||
/// </summary>
|
||||
public static string ExtractHex(string hash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hash);
|
||||
|
||||
const string prefix = "blake3:";
|
||||
if (hash.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hash[prefix.Length..].ToLowerInvariant();
|
||||
}
|
||||
|
||||
return hash.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string SerializeSymbolEntry(SymbolEntry symbol)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
|
||||
static string N(string? value) => value?.Trim() ?? string.Empty;
|
||||
static string NInt(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"addr={symbol.Address:x16}|size={symbol.Size}|m={N(symbol.MangledName)}|d={N(symbol.DemangledName)}|t={symbol.Type}|b={symbol.Binding}|sf={N(symbol.SourceFile)}|sl={NInt(symbol.SourceLine)}|h={N(symbol.ContentHash)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Resolution;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of symbol resolver using the symbol repository.
|
||||
/// </summary>
|
||||
public sealed class DefaultSymbolResolver : ISymbolResolver
|
||||
{
|
||||
private readonly ISymbolRepository _repository;
|
||||
|
||||
public DefaultSymbolResolver(ISymbolRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolResolution?> ResolveAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolResolution>> ResolveBatchAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<SymbolResolution>();
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
SymbolResolution? resolution = null;
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
resolution = new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(resolution ?? new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolEntry>> GetAllSymbolsAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
SymbolType? typeFilter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var symbols = manifests
|
||||
.SelectMany(m => m.Symbols)
|
||||
.Where(s => !typeFilter.HasValue || s.Type == typeFilter.Value)
|
||||
.DistinctBy(s => s.Address)
|
||||
.OrderBy(s => s.Address)
|
||||
.ToList();
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private static SymbolEntry? FindSymbolAtAddress(IReadOnlyList<SymbolEntry> symbols, ulong address)
|
||||
{
|
||||
// Binary search for the symbol containing the address
|
||||
var left = 0;
|
||||
var right = symbols.Count - 1;
|
||||
SymbolEntry? candidate = null;
|
||||
|
||||
while (left <= right)
|
||||
{
|
||||
var mid = left + (right - left) / 2;
|
||||
var symbol = symbols[mid];
|
||||
|
||||
if (address >= symbol.Address && address < symbol.Address + symbol.Size)
|
||||
{
|
||||
return symbol; // Exact match within symbol bounds
|
||||
}
|
||||
|
||||
if (address >= symbol.Address)
|
||||
{
|
||||
candidate = symbol;
|
||||
left = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a candidate and address is within reasonable range, return it
|
||||
if (candidate is not null && address >= candidate.Address && address < candidate.Address + Math.Max(candidate.Size, 4096))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Infrastructure.Resolution;
|
||||
using StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Symbols infrastructure.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds in-memory symbol services for development and testing.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsInMemory(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolRepository, InMemorySymbolRepository>();
|
||||
services.TryAddSingleton<ISymbolBlobStore, InMemorySymbolBlobStore>();
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the default symbol resolver.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolResolver(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Infrastructure.Hashing;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol blob store for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolBlobStore : ISymbolBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BlobEntry> _blobs = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolBlobUploadResult> UploadAsync(
|
||||
Stream content,
|
||||
string tenantId,
|
||||
string debugId,
|
||||
string? fileName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var data = ms.ToArray();
|
||||
|
||||
var contentHash = SymbolHashing.ComputeHash(data);
|
||||
var blobUri = $"cas://symbols/{tenantId}/{debugId}/{SymbolHashing.ExtractHex(contentHash)}";
|
||||
|
||||
var isDuplicate = _blobs.ContainsKey(blobUri);
|
||||
|
||||
var entry = new BlobEntry(
|
||||
Data: data,
|
||||
ContentHash: contentHash,
|
||||
TenantId: tenantId,
|
||||
DebugId: debugId,
|
||||
FileName: fileName,
|
||||
ContentType: "application/octet-stream",
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
_blobs[blobUri] = entry;
|
||||
|
||||
return new SymbolBlobUploadResult
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = contentHash,
|
||||
Size = data.Length,
|
||||
IsDuplicate = isDuplicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Stream?> DownloadAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(entry.Data));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(blobUri));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolBlobMetadata?> GetMetadataAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<SymbolBlobMetadata?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<SymbolBlobMetadata?>(new SymbolBlobMetadata
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = entry.ContentHash,
|
||||
Size = entry.Data.Length,
|
||||
ContentType = entry.ContentType,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
TenantId = entry.TenantId,
|
||||
DebugId = entry.DebugId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteAsync(string blobUri, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.TryRemove(blobUri, out _));
|
||||
}
|
||||
|
||||
private sealed record BlobEntry(
|
||||
byte[] Data,
|
||||
string ContentHash,
|
||||
string TenantId,
|
||||
string DebugId,
|
||||
string? FileName,
|
||||
string ContentType,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol repository for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolRepository : ISymbolRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SymbolManifest> _manifests = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _debugIdIndex = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _codeIdIndex = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> StoreManifestAsync(SymbolManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests[manifest.ManifestId] = manifest;
|
||||
|
||||
// Update debug ID index
|
||||
_debugIdIndex.AddOrUpdate(
|
||||
manifest.DebugId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
|
||||
// Update code ID index if present
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId))
|
||||
{
|
||||
_codeIdIndex.AddOrUpdate(
|
||||
manifest.CodeId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
}
|
||||
|
||||
return Task.FromResult(manifest.ManifestId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolManifest?> GetManifestAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests.TryGetValue(manifestId, out var manifest);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_debugIdIndex.TryGetValue(debugId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByCodeIdAsync(
|
||||
string codeId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_codeIdIndex.TryGetValue(codeId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolQueryResult> QueryManifestsAsync(SymbolQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = _manifests.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
manifests = manifests.Where(m => m.TenantId == query.TenantId);
|
||||
if (!string.IsNullOrEmpty(query.DebugId))
|
||||
manifests = manifests.Where(m => m.DebugId == query.DebugId);
|
||||
if (!string.IsNullOrEmpty(query.CodeId))
|
||||
manifests = manifests.Where(m => m.CodeId == query.CodeId);
|
||||
if (!string.IsNullOrEmpty(query.BinaryName))
|
||||
manifests = manifests.Where(m => m.BinaryName.Contains(query.BinaryName, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(query.Platform))
|
||||
manifests = manifests.Where(m => m.Platform == query.Platform);
|
||||
if (query.Format.HasValue)
|
||||
manifests = manifests.Where(m => m.Format == query.Format.Value);
|
||||
if (query.CreatedAfter.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt >= query.CreatedAfter.Value);
|
||||
if (query.CreatedBefore.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt <= query.CreatedBefore.Value);
|
||||
if (query.HasDsse.HasValue)
|
||||
manifests = manifests.Where(m => !string.IsNullOrEmpty(m.DsseDigest) == query.HasDsse.Value);
|
||||
|
||||
var total = manifests.Count();
|
||||
|
||||
manifests = query.SortDescending
|
||||
? manifests.OrderByDescending(m => m.CreatedAt)
|
||||
: manifests.OrderBy(m => m.CreatedAt);
|
||||
|
||||
var result = manifests
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new SymbolQueryResult
|
||||
{
|
||||
Manifests = result,
|
||||
TotalCount = total,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_manifests.ContainsKey(manifestId));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteManifestAsync(string manifestId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_manifests.TryRemove(manifestId, out var manifest))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Remove from indexes
|
||||
if (_debugIdIndex.TryGetValue(manifest.DebugId, out var debugSet))
|
||||
{
|
||||
debugSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId) && _codeIdIndex.TryGetValue(manifest.CodeId, out var codeSet))
|
||||
{
|
||||
codeSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Symbols.Infrastructure Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Symbols/StellaOps.Symbols.Infrastructure/StellaOps.Symbols.Infrastructure.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
Reference in New Issue
Block a user