|
| 1 | +#nullable enable |
| 2 | +using System.Text.Json; |
| 3 | +using KeelMatrix.QueryWatch.Cli.IO; |
| 4 | +using KeelMatrix.QueryWatch.Cli.Model; |
| 5 | +using KeelMatrix.QueryWatch.Cli.Options; |
| 6 | + |
| 7 | +namespace KeelMatrix.QueryWatch.Cli.Core { |
| 8 | + internal sealed class Runner { |
| 9 | + public async Task<int> ExecuteAsync(CommandLineOptions opts) { |
| 10 | + IReadOnlyList<Summary> summaries; |
| 11 | + try { |
| 12 | + summaries = await SummaryLoader.LoadAsync(opts.Inputs); |
| 13 | + } |
| 14 | + catch (FileNotFoundException fnf) { |
| 15 | + await Console.Error.WriteLineAsync(fnf.Message); |
| 16 | + return ExitCodes.InputFileNotFound; |
| 17 | + } |
| 18 | + catch (JsonException jex) { |
| 19 | + await Console.Error.WriteLineAsync(jex.Message); |
| 20 | + return ExitCodes.JsonParseError; |
| 21 | + } |
| 22 | + |
| 23 | + var agg = Aggregated.From(summaries); |
| 24 | + |
| 25 | + Console.WriteLine($"QueryWatch Summary (schema {agg.Schema}, files {summaries.Count})"); |
| 26 | + Console.WriteLine($" - Started: {agg.StartedAt:u}"); |
| 27 | + Console.WriteLine($" - Stopped: {agg.StoppedAt:u}"); |
| 28 | + Console.WriteLine($" - Queries: {agg.TotalQueries}"); |
| 29 | + Console.WriteLine($" - Total: {agg.TotalDurationMs:N2} ms"); |
| 30 | + Console.WriteLine($" - Average: {agg.AverageDurationMs:N2} ms"); |
| 31 | + if (agg.SampledEventsCount < agg.TotalQueries) |
| 32 | + Console.WriteLine($" - Note: Events contain {agg.SampledEventsCount} sampled entries (top-N), total queries = {agg.TotalQueries}."); |
| 33 | + |
| 34 | + var exitCode = ExitCodes.Ok; |
| 35 | + |
| 36 | + // Hard budgets |
| 37 | + var violations = new List<string>(); |
| 38 | + if (opts.MaxQueries.HasValue && agg.TotalQueries > opts.MaxQueries.Value) |
| 39 | + violations.Add($"MaxQueries={opts.MaxQueries.Value} but executed {agg.TotalQueries}."); |
| 40 | + if (opts.MaxAverageMs.HasValue && agg.AverageDurationMs > opts.MaxAverageMs.Value) |
| 41 | + violations.Add($"MaxAverageMs={opts.MaxAverageMs.Value} but actual {agg.AverageDurationMs:N2} ms."); |
| 42 | + if (opts.MaxTotalMs.HasValue && agg.TotalDurationMs > opts.MaxTotalMs.Value) |
| 43 | + violations.Add($"MaxTotalMs={opts.MaxTotalMs.Value} but actual {agg.TotalDurationMs:N2} ms."); |
| 44 | + |
| 45 | + if (violations.Count > 0) { |
| 46 | + await Console.Error.WriteLineAsync("Budget violations:"); |
| 47 | + foreach (var v in violations) await Console.Error.WriteLineAsync(" - " + v); |
| 48 | + exitCode = Math.Max(exitCode, ExitCodes.BudgetExceeded); |
| 49 | + } |
| 50 | + |
| 51 | + // Pattern budgets |
| 52 | + var patternFindings = new List<(PatternBudget budget, int count, bool over)>(); |
| 53 | + if (opts.PatternBudgetSpecs.Count > 0) { |
| 54 | + var texts = agg.Events.Select(e => e.Text ?? string.Empty); |
| 55 | + foreach (var spec in opts.PatternBudgetSpecs) { |
| 56 | + if (!PatternBudget.TryParse(spec, out var b, out var err)) { |
| 57 | + await Console.Error.WriteLineAsync($"Invalid --budget value '{spec}': {err}"); |
| 58 | + return ExitCodes.InvalidArguments; |
| 59 | + } |
| 60 | + var count = texts.Count(t => b!.Regex.IsMatch(t)); |
| 61 | + var over = count > b!.MaxCount; |
| 62 | + patternFindings.Add((b!, count, over)); |
| 63 | + if (over) exitCode = Math.Max(exitCode, ExitCodes.BudgetExceeded); |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + // Baseline comparison |
| 68 | + var baselineViolations = new List<string>(); |
| 69 | + Summary? baseline = null; |
| 70 | + if (!string.IsNullOrWhiteSpace(opts.BaselinePath) && File.Exists(opts.BaselinePath)) { |
| 71 | + try { |
| 72 | + await using var bstream = File.OpenRead(opts.BaselinePath); |
| 73 | + baseline = await JsonSerializer.DeserializeAsync<Summary>(bstream).ConfigureAwait(false); |
| 74 | + } |
| 75 | + catch (Exception ex) { |
| 76 | + await Console.Error.WriteLineAsync($"Baseline parse failed (ignored): {ex.Message}"); |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + if (!string.IsNullOrWhiteSpace(opts.BaselinePath) && baseline is null && opts.WriteBaseline) { |
| 81 | + try { |
| 82 | + var dir = Path.GetDirectoryName(opts.BaselinePath); |
| 83 | + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); |
| 84 | + await using var outStream = File.Create(opts.BaselinePath!); |
| 85 | + await JsonSerializer.SerializeAsync(outStream, agg.ToSummary(), new JsonSerializerOptions { WriteIndented = true }).ConfigureAwait(false); |
| 86 | + Console.WriteLine($"Baseline written: {opts.BaselinePath}"); |
| 87 | + } |
| 88 | + catch (Exception ex) { |
| 89 | + await Console.Error.WriteLineAsync($"Failed to write baseline: {ex.Message}"); |
| 90 | + } |
| 91 | + } |
| 92 | + else if (baseline is not null) { |
| 93 | + var tol = opts.BaselineAllowPercent / 100.0; |
| 94 | + double allowedQueries = baseline.TotalQueries * (1.0 + tol); |
| 95 | + double allowedAvg = baseline.AverageDurationMs * (1.0 + tol); |
| 96 | + double allowedTotal = baseline.TotalDurationMs * (1.0 + tol); |
| 97 | + |
| 98 | + if (agg.TotalQueries > allowedQueries) baselineViolations.Add($"Queries increased beyond +{opts.BaselineAllowPercent:N2}%: {baseline.TotalQueries} -> {agg.TotalQueries}"); |
| 99 | + if (agg.AverageDurationMs > allowedAvg) baselineViolations.Add($"AverageMs increased beyond +{opts.BaselineAllowPercent:N2}%: {baseline.AverageDurationMs:N2} -> {agg.AverageDurationMs:N2}"); |
| 100 | + if (agg.TotalDurationMs > allowedTotal) baselineViolations.Add($"TotalMs increased beyond +{opts.BaselineAllowPercent:N2}%: {baseline.TotalDurationMs:N2} -> {agg.TotalDurationMs:N2}"); |
| 101 | + |
| 102 | + if (baselineViolations.Count > 0) { |
| 103 | + await Console.Error.WriteLineAsync("Baseline regressions:"); |
| 104 | + foreach (var v in baselineViolations) await Console.Error.WriteLineAsync(" - " + v); |
| 105 | + exitCode = Math.Max(exitCode, ExitCodes.BaselineRegression); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + // Step summary for GitHub Actions |
| 110 | + var stepSummaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); |
| 111 | + if (!string.IsNullOrWhiteSpace(stepSummaryPath)) { |
| 112 | + var md = StepSummaryBuilder.Build(agg, opts.MaxQueries, opts.MaxAverageMs, opts.MaxTotalMs, violations, patternFindings, baseline, opts.BaselineAllowPercent, baselineViolations); |
| 113 | + try { await File.AppendAllTextAsync(stepSummaryPath!, md); } catch { /* ignore */ } |
| 114 | + } |
| 115 | + |
| 116 | + if (exitCode == ExitCodes.Ok) Console.WriteLine("QueryWatch gate: OK"); |
| 117 | + return exitCode; |
| 118 | + } |
| 119 | + } |
| 120 | +} |
0 commit comments