Add tests and implement StubBearer authentication for Signer endpoints
- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
		@@ -0,0 +1,104 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Bench.ScannerAnalyzers;
 | 
			
		||||
 | 
			
		||||
internal sealed record BenchmarkConfig
 | 
			
		||||
{
 | 
			
		||||
    [JsonPropertyName("iterations")]
 | 
			
		||||
    public int? Iterations { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("thresholdMs")]
 | 
			
		||||
    public double? ThresholdMs { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("scenarios")]
 | 
			
		||||
    public List<BenchmarkScenarioConfig> Scenarios { get; init; } = new();
 | 
			
		||||
 | 
			
		||||
    public static async Task<BenchmarkConfig> LoadAsync(string path)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(path))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Config path is required.", nameof(path));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await using var stream = File.OpenRead(path);
 | 
			
		||||
        var config = await JsonSerializer.DeserializeAsync<BenchmarkConfig>(stream, SerializerOptions).ConfigureAwait(false);
 | 
			
		||||
        if (config is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Failed to parse benchmark config '{path}'.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (config.Scenarios.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("config.scenarios must declare at least one scenario.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var scenario in config.Scenarios)
 | 
			
		||||
        {
 | 
			
		||||
            scenario.Validate();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return config;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static JsonSerializerOptions SerializerOptions => new()
 | 
			
		||||
    {
 | 
			
		||||
        PropertyNameCaseInsensitive = true,
 | 
			
		||||
        ReadCommentHandling = JsonCommentHandling.Skip,
 | 
			
		||||
        AllowTrailingCommas = true,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed record BenchmarkScenarioConfig
 | 
			
		||||
{
 | 
			
		||||
    [JsonPropertyName("id")]
 | 
			
		||||
    public string? Id { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("label")]
 | 
			
		||||
    public string? Label { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("root")]
 | 
			
		||||
    public string? Root { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("analyzers")]
 | 
			
		||||
    public List<string>? Analyzers { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("matcher")]
 | 
			
		||||
    public string? Matcher { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("parser")]
 | 
			
		||||
    public string? Parser { get; init; }
 | 
			
		||||
 | 
			
		||||
    [JsonPropertyName("thresholdMs")]
 | 
			
		||||
    public double? ThresholdMs { get; init; }
 | 
			
		||||
 | 
			
		||||
    public bool HasAnalyzers => Analyzers is { Count: > 0 };
 | 
			
		||||
 | 
			
		||||
    public void Validate()
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(Id))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("scenario.id is required.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(Root))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Scenario '{Id}' must specify a root path.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (HasAnalyzers)
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(Parser))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Scenario '{Id}' must specify parser or analyzers.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(Matcher))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Scenario '{Id}' must specify matcher when parser is used.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,302 @@
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using StellaOps.Bench.ScannerAnalyzers.Scenarios;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Bench.ScannerAnalyzers;
 | 
			
		||||
 | 
			
		||||
internal static class Program
 | 
			
		||||
{
 | 
			
		||||
    public static async Task<int> Main(string[] args)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var options = ProgramOptions.Parse(args);
 | 
			
		||||
            var config = await BenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            var iterations = options.Iterations ?? config.Iterations ?? 5;
 | 
			
		||||
            var thresholdMs = options.ThresholdMs ?? config.ThresholdMs ?? 5000;
 | 
			
		||||
            var repoRoot = ResolveRepoRoot(options.RepoRoot, options.ConfigPath);
 | 
			
		||||
 | 
			
		||||
            var results = new List<ScenarioResult>();
 | 
			
		||||
            var failures = new List<string>();
 | 
			
		||||
 | 
			
		||||
            foreach (var scenario in config.Scenarios)
 | 
			
		||||
            {
 | 
			
		||||
                var runner = ScenarioRunnerFactory.Create(scenario);
 | 
			
		||||
                var scenarioRoot = ResolveScenarioRoot(repoRoot, scenario.Root!);
 | 
			
		||||
 | 
			
		||||
                var execution = await runner.ExecuteAsync(scenarioRoot, iterations, CancellationToken.None).ConfigureAwait(false);
 | 
			
		||||
                var stats = ScenarioStatistics.FromDurations(execution.Durations);
 | 
			
		||||
                var scenarioThreshold = scenario.ThresholdMs ?? thresholdMs;
 | 
			
		||||
 | 
			
		||||
                results.Add(new ScenarioResult(
 | 
			
		||||
                    scenario.Id!,
 | 
			
		||||
                    scenario.Label ?? scenario.Id!,
 | 
			
		||||
                    execution.SampleCount,
 | 
			
		||||
                    stats.MeanMs,
 | 
			
		||||
                    stats.P95Ms,
 | 
			
		||||
                    stats.MaxMs,
 | 
			
		||||
                    iterations));
 | 
			
		||||
 | 
			
		||||
                if (stats.MaxMs > scenarioThreshold)
 | 
			
		||||
                {
 | 
			
		||||
                    failures.Add($"{scenario.Id} exceeded threshold: {stats.MaxMs:F2} ms > {scenarioThreshold:F2} ms");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            TablePrinter.Print(results);
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(options.OutPath))
 | 
			
		||||
            {
 | 
			
		||||
                CsvWriter.Write(options.OutPath!, results);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (failures.Count > 0)
 | 
			
		||||
            {
 | 
			
		||||
                Console.Error.WriteLine();
 | 
			
		||||
                Console.Error.WriteLine("Performance threshold exceeded:");
 | 
			
		||||
                foreach (var failure in failures)
 | 
			
		||||
                {
 | 
			
		||||
                    Console.Error.WriteLine($" - {failure}");
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            Console.Error.WriteLine(ex.Message);
 | 
			
		||||
            return 1;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string ResolveRepoRoot(string? overridePath, string configPath)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(overridePath))
 | 
			
		||||
        {
 | 
			
		||||
            return Path.GetFullPath(overridePath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var configDirectory = Path.GetDirectoryName(configPath);
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(configDirectory))
 | 
			
		||||
        {
 | 
			
		||||
            return Directory.GetCurrentDirectory();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Path.GetFullPath(Path.Combine(configDirectory, "..", ".."));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string ResolveScenarioRoot(string repoRoot, string relativeRoot)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(relativeRoot))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Scenario root is required.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var combined = Path.GetFullPath(Path.Combine(repoRoot, relativeRoot));
 | 
			
		||||
        if (!PathUtilities.IsWithinRoot(repoRoot, combined))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Scenario root '{relativeRoot}' escapes repository root '{repoRoot}'.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!Directory.Exists(combined))
 | 
			
		||||
        {
 | 
			
		||||
            throw new DirectoryNotFoundException($"Scenario root '{combined}' does not exist.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return combined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record ProgramOptions(string ConfigPath, int? Iterations, double? ThresholdMs, string? OutPath, string? RepoRoot)
 | 
			
		||||
    {
 | 
			
		||||
        public static ProgramOptions Parse(string[] args)
 | 
			
		||||
        {
 | 
			
		||||
            var configPath = DefaultConfigPath();
 | 
			
		||||
            int? iterations = null;
 | 
			
		||||
            double? thresholdMs = null;
 | 
			
		||||
            string? outPath = null;
 | 
			
		||||
            string? repoRoot = null;
 | 
			
		||||
 | 
			
		||||
            for (var index = 0; index < args.Length; index++)
 | 
			
		||||
            {
 | 
			
		||||
                var current = args[index];
 | 
			
		||||
                switch (current)
 | 
			
		||||
                {
 | 
			
		||||
                    case "--config":
 | 
			
		||||
                        EnsureNext(args, index);
 | 
			
		||||
                        configPath = Path.GetFullPath(args[++index]);
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "--iterations":
 | 
			
		||||
                        EnsureNext(args, index);
 | 
			
		||||
                        iterations = int.Parse(args[++index], CultureInfo.InvariantCulture);
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "--threshold-ms":
 | 
			
		||||
                        EnsureNext(args, index);
 | 
			
		||||
                        thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture);
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "--out":
 | 
			
		||||
                        EnsureNext(args, index);
 | 
			
		||||
                        outPath = args[++index];
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "--repo-root":
 | 
			
		||||
                    case "--samples":
 | 
			
		||||
                        EnsureNext(args, index);
 | 
			
		||||
                        repoRoot = args[++index];
 | 
			
		||||
                        break;
 | 
			
		||||
                    default:
 | 
			
		||||
                        throw new ArgumentException($"Unknown argument: {current}", nameof(args));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new ProgramOptions(configPath, iterations, thresholdMs, outPath, repoRoot);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private static string DefaultConfigPath()
 | 
			
		||||
        {
 | 
			
		||||
            var binaryDir = AppContext.BaseDirectory;
 | 
			
		||||
            var projectRoot = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", ".."));
 | 
			
		||||
            var configDirectory = Path.GetFullPath(Path.Combine(projectRoot, ".."));
 | 
			
		||||
            return Path.Combine(configDirectory, "config.json");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private static void EnsureNext(string[] args, int index)
 | 
			
		||||
        {
 | 
			
		||||
            if (index + 1 >= args.Length)
 | 
			
		||||
            {
 | 
			
		||||
                throw new ArgumentException("Missing value for argument.", nameof(args));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record ScenarioResult(
 | 
			
		||||
        string Id,
 | 
			
		||||
        string Label,
 | 
			
		||||
        int SampleCount,
 | 
			
		||||
        double MeanMs,
 | 
			
		||||
        double P95Ms,
 | 
			
		||||
        double MaxMs,
 | 
			
		||||
        int Iterations);
 | 
			
		||||
 | 
			
		||||
    private sealed record ScenarioStatistics(double MeanMs, double P95Ms, double MaxMs)
 | 
			
		||||
    {
 | 
			
		||||
        public static ScenarioStatistics FromDurations(IReadOnlyList<double> durations)
 | 
			
		||||
        {
 | 
			
		||||
            if (durations.Count == 0)
 | 
			
		||||
            {
 | 
			
		||||
                return new ScenarioStatistics(0, 0, 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var sorted = durations.ToArray();
 | 
			
		||||
            Array.Sort(sorted);
 | 
			
		||||
 | 
			
		||||
            var total = 0d;
 | 
			
		||||
            foreach (var value in durations)
 | 
			
		||||
            {
 | 
			
		||||
                total += value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var mean = total / durations.Count;
 | 
			
		||||
            var p95 = Percentile(sorted, 95);
 | 
			
		||||
            var max = sorted[^1];
 | 
			
		||||
 | 
			
		||||
            return new ScenarioStatistics(mean, p95, max);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private static double Percentile(IReadOnlyList<double> sorted, double percentile)
 | 
			
		||||
        {
 | 
			
		||||
            if (sorted.Count == 0)
 | 
			
		||||
            {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var rank = (percentile / 100d) * (sorted.Count - 1);
 | 
			
		||||
            var lower = (int)Math.Floor(rank);
 | 
			
		||||
            var upper = (int)Math.Ceiling(rank);
 | 
			
		||||
            var weight = rank - lower;
 | 
			
		||||
 | 
			
		||||
            if (upper >= sorted.Count)
 | 
			
		||||
            {
 | 
			
		||||
                return sorted[lower];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return sorted[lower] + weight * (sorted[upper] - sorted[lower]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static class TablePrinter
 | 
			
		||||
    {
 | 
			
		||||
        public static void Print(IEnumerable<ScenarioResult> results)
 | 
			
		||||
        {
 | 
			
		||||
            Console.WriteLine("Scenario                     | Count |   Mean(ms) |    P95(ms) |     Max(ms)");
 | 
			
		||||
            Console.WriteLine("---------------------------- | ----- | --------- | --------- | ----------");
 | 
			
		||||
            foreach (var row in results)
 | 
			
		||||
            {
 | 
			
		||||
                Console.WriteLine(FormatRow(row));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private static string FormatRow(ScenarioResult row)
 | 
			
		||||
        {
 | 
			
		||||
            var idColumn = row.Id.Length <= 28
 | 
			
		||||
                ? row.Id.PadRight(28)
 | 
			
		||||
                : row.Id[..28];
 | 
			
		||||
 | 
			
		||||
            return string.Join(" | ", new[]
 | 
			
		||||
            {
 | 
			
		||||
                idColumn,
 | 
			
		||||
                row.SampleCount.ToString(CultureInfo.InvariantCulture).PadLeft(5),
 | 
			
		||||
                row.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
 | 
			
		||||
                row.P95Ms.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
 | 
			
		||||
                row.MaxMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10),
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static class CsvWriter
 | 
			
		||||
    {
 | 
			
		||||
        public static void Write(string path, IEnumerable<ScenarioResult> results)
 | 
			
		||||
        {
 | 
			
		||||
            var resolvedPath = Path.GetFullPath(path);
 | 
			
		||||
            var directory = Path.GetDirectoryName(resolvedPath);
 | 
			
		||||
            if (!string.IsNullOrEmpty(directory))
 | 
			
		||||
            {
 | 
			
		||||
                Directory.CreateDirectory(directory);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using var stream = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None);
 | 
			
		||||
            using var writer = new StreamWriter(stream);
 | 
			
		||||
            writer.WriteLine("scenario,iterations,sample_count,mean_ms,p95_ms,max_ms");
 | 
			
		||||
 | 
			
		||||
            foreach (var row in results)
 | 
			
		||||
            {
 | 
			
		||||
                writer.Write(row.Id);
 | 
			
		||||
                writer.Write(',');
 | 
			
		||||
                writer.Write(row.Iterations.ToString(CultureInfo.InvariantCulture));
 | 
			
		||||
                writer.Write(',');
 | 
			
		||||
                writer.Write(row.SampleCount.ToString(CultureInfo.InvariantCulture));
 | 
			
		||||
                writer.Write(',');
 | 
			
		||||
                writer.Write(row.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
 | 
			
		||||
                writer.Write(',');
 | 
			
		||||
                writer.Write(row.P95Ms.ToString("F4", CultureInfo.InvariantCulture));
 | 
			
		||||
                writer.Write(',');
 | 
			
		||||
                writer.Write(row.MaxMs.ToString("F4", CultureInfo.InvariantCulture));
 | 
			
		||||
                writer.WriteLine();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    internal static class PathUtilities
 | 
			
		||||
    {
 | 
			
		||||
        public static bool IsWithinRoot(string root, string candidate)
 | 
			
		||||
        {
 | 
			
		||||
            var relative = Path.GetRelativePath(root, candidate);
 | 
			
		||||
            if (string.IsNullOrEmpty(relative) || relative == ".")
 | 
			
		||||
            {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return !relative.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relative);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,279 @@
 | 
			
		||||
using System.Diagnostics;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.RegularExpressions;
 | 
			
		||||
using StellaOps.Scanner.Analyzers.Lang;
 | 
			
		||||
using StellaOps.Scanner.Analyzers.Lang.Java;
 | 
			
		||||
using StellaOps.Scanner.Analyzers.Lang.Node;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Bench.ScannerAnalyzers.Scenarios;
 | 
			
		||||
 | 
			
		||||
internal interface IScenarioRunner
 | 
			
		||||
{
 | 
			
		||||
    Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed record ScenarioExecutionResult(double[] Durations, int SampleCount);
 | 
			
		||||
 | 
			
		||||
internal static class ScenarioRunnerFactory
 | 
			
		||||
{
 | 
			
		||||
    public static IScenarioRunner Create(BenchmarkScenarioConfig scenario)
 | 
			
		||||
    {
 | 
			
		||||
        if (scenario.HasAnalyzers)
 | 
			
		||||
        {
 | 
			
		||||
            return new LanguageAnalyzerScenarioRunner(scenario.Analyzers!);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(scenario.Parser) || string.IsNullOrWhiteSpace(scenario.Matcher))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Scenario '{scenario.Id}' missing parser or matcher configuration.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new MetadataWalkScenarioRunner(scenario.Parser, scenario.Matcher);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class LanguageAnalyzerScenarioRunner : IScenarioRunner
 | 
			
		||||
{
 | 
			
		||||
    private readonly IReadOnlyList<Func<ILanguageAnalyzer>> _analyzerFactories;
 | 
			
		||||
 | 
			
		||||
    public LanguageAnalyzerScenarioRunner(IEnumerable<string> analyzerIds)
 | 
			
		||||
    {
 | 
			
		||||
        if (analyzerIds is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(analyzerIds));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _analyzerFactories = analyzerIds
 | 
			
		||||
            .Where(static id => !string.IsNullOrWhiteSpace(id))
 | 
			
		||||
            .Select(CreateFactory)
 | 
			
		||||
            .ToArray();
 | 
			
		||||
 | 
			
		||||
        if (_analyzerFactories.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("At least one analyzer id must be provided.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (iterations <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var analyzers = _analyzerFactories.Select(factory => factory()).ToArray();
 | 
			
		||||
        var engine = new LanguageAnalyzerEngine(analyzers);
 | 
			
		||||
        var durations = new double[iterations];
 | 
			
		||||
        var componentCount = -1;
 | 
			
		||||
 | 
			
		||||
        for (var i = 0; i < iterations; i++)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            var context = new LanguageAnalyzerContext(rootPath, TimeProvider.System);
 | 
			
		||||
            var stopwatch = Stopwatch.StartNew();
 | 
			
		||||
            var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            stopwatch.Stop();
 | 
			
		||||
 | 
			
		||||
            durations[i] = stopwatch.Elapsed.TotalMilliseconds;
 | 
			
		||||
 | 
			
		||||
            var currentCount = result.Components.Count;
 | 
			
		||||
            if (componentCount < 0)
 | 
			
		||||
            {
 | 
			
		||||
                componentCount = currentCount;
 | 
			
		||||
            }
 | 
			
		||||
            else if (componentCount != currentCount)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Analyzer output count changed between iterations ({componentCount} vs {currentCount}).");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (componentCount < 0)
 | 
			
		||||
        {
 | 
			
		||||
            componentCount = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ScenarioExecutionResult(durations, componentCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Func<ILanguageAnalyzer> CreateFactory(string analyzerId)
 | 
			
		||||
    {
 | 
			
		||||
        var id = analyzerId.Trim().ToLowerInvariant();
 | 
			
		||||
        return id switch
 | 
			
		||||
        {
 | 
			
		||||
            "java" => static () => new JavaLanguageAnalyzer(),
 | 
			
		||||
            "node" => static () => new NodeLanguageAnalyzer(),
 | 
			
		||||
            _ => throw new InvalidOperationException($"Unsupported analyzer '{analyzerId}'."),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class MetadataWalkScenarioRunner : IScenarioRunner
 | 
			
		||||
{
 | 
			
		||||
    private readonly Regex _matcher;
 | 
			
		||||
    private readonly string _parserKind;
 | 
			
		||||
 | 
			
		||||
    public MetadataWalkScenarioRunner(string parserKind, string globPattern)
 | 
			
		||||
    {
 | 
			
		||||
        _parserKind = parserKind?.Trim().ToLowerInvariant() ?? throw new ArgumentNullException(nameof(parserKind));
 | 
			
		||||
        _matcher = GlobToRegex(globPattern ?? throw new ArgumentNullException(nameof(globPattern)));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<ScenarioExecutionResult> ExecuteAsync(string rootPath, int iterations, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (iterations <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var durations = new double[iterations];
 | 
			
		||||
        var sampleCount = -1;
 | 
			
		||||
 | 
			
		||||
        for (var i = 0; i < iterations; i++)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            var stopwatch = Stopwatch.StartNew();
 | 
			
		||||
            var files = EnumerateMatchingFiles(rootPath);
 | 
			
		||||
            if (files.Count == 0)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Parser '{_parserKind}' matched zero files under '{rootPath}'.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var file in files)
 | 
			
		||||
            {
 | 
			
		||||
                cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
                await ParseAsync(file).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            stopwatch.Stop();
 | 
			
		||||
            durations[i] = stopwatch.Elapsed.TotalMilliseconds;
 | 
			
		||||
 | 
			
		||||
            if (sampleCount < 0)
 | 
			
		||||
            {
 | 
			
		||||
                sampleCount = files.Count;
 | 
			
		||||
            }
 | 
			
		||||
            else if (sampleCount != files.Count)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"File count changed between iterations ({sampleCount} vs {files.Count}).");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (sampleCount < 0)
 | 
			
		||||
        {
 | 
			
		||||
            sampleCount = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ScenarioExecutionResult(durations, sampleCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async ValueTask ParseAsync(string filePath)
 | 
			
		||||
    {
 | 
			
		||||
        switch (_parserKind)
 | 
			
		||||
        {
 | 
			
		||||
            case "node":
 | 
			
		||||
                {
 | 
			
		||||
                    using var stream = File.OpenRead(filePath);
 | 
			
		||||
                    using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
                    if (!document.RootElement.TryGetProperty("name", out var name) || name.ValueKind != JsonValueKind.String)
 | 
			
		||||
                    {
 | 
			
		||||
                        throw new InvalidOperationException($"package.json '{filePath}' missing name.");
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (!document.RootElement.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.String)
 | 
			
		||||
                    {
 | 
			
		||||
                        throw new InvalidOperationException($"package.json '{filePath}' missing version.");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                break;
 | 
			
		||||
            case "python":
 | 
			
		||||
                {
 | 
			
		||||
                    var (name, version) = await ParsePythonMetadataAsync(filePath).ConfigureAwait(false);
 | 
			
		||||
                    if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(version))
 | 
			
		||||
                    {
 | 
			
		||||
                        throw new InvalidOperationException($"METADATA '{filePath}' missing Name/Version.");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                break;
 | 
			
		||||
            default:
 | 
			
		||||
                throw new InvalidOperationException($"Unknown parser '{_parserKind}'.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task<(string? Name, string? Version)> ParsePythonMetadataAsync(string filePath)
 | 
			
		||||
    {
 | 
			
		||||
        using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
 | 
			
		||||
        using var reader = new StreamReader(stream);
 | 
			
		||||
 | 
			
		||||
        string? name = null;
 | 
			
		||||
        string? version = null;
 | 
			
		||||
 | 
			
		||||
        while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
 | 
			
		||||
        {
 | 
			
		||||
            if (line.StartsWith("Name:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                name ??= line[5..].Trim();
 | 
			
		||||
            }
 | 
			
		||||
            else if (line.StartsWith("Version:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                version ??= line[8..].Trim();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(version))
 | 
			
		||||
            {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (name, version);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private IReadOnlyList<string> EnumerateMatchingFiles(string rootPath)
 | 
			
		||||
    {
 | 
			
		||||
        var files = new List<string>();
 | 
			
		||||
        var stack = new Stack<string>();
 | 
			
		||||
        stack.Push(rootPath);
 | 
			
		||||
 | 
			
		||||
        while (stack.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var current = stack.Pop();
 | 
			
		||||
            foreach (var directory in Directory.EnumerateDirectories(current))
 | 
			
		||||
            {
 | 
			
		||||
                stack.Push(directory);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var file in Directory.EnumerateFiles(current))
 | 
			
		||||
            {
 | 
			
		||||
                var relative = Path.GetRelativePath(rootPath, file).Replace('\\', '/');
 | 
			
		||||
                if (_matcher.IsMatch(relative))
 | 
			
		||||
                {
 | 
			
		||||
                    files.Add(file);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return files;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Regex GlobToRegex(string pattern)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(pattern))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Glob pattern is required.", nameof(pattern));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = pattern.Replace("\\", "/");
 | 
			
		||||
        normalized = normalized.Replace("**", "\u0001");
 | 
			
		||||
        normalized = normalized.Replace("*", "\u0002");
 | 
			
		||||
 | 
			
		||||
        var escaped = Regex.Escape(normalized);
 | 
			
		||||
        escaped = escaped.Replace("\u0001/", "(?:.*/)?", StringComparison.Ordinal);
 | 
			
		||||
        escaped = escaped.Replace("\u0001", ".*", StringComparison.Ordinal);
 | 
			
		||||
        escaped = escaped.Replace("\u0002", "[^/]*", StringComparison.Ordinal);
 | 
			
		||||
 | 
			
		||||
        return new Regex("^" + escaped + "$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <OutputType>Exe</OutputType>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <LangVersion>preview</LangVersion>
 | 
			
		||||
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\..\src\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
		Reference in New Issue
	
	Block a user