Skip to content

Commit b4d0d82

Browse files
committed
CLI refactor: split Program into Options/IO/Core/Model/Output, keep behavior (multi-file, budgets, baseline, PR summary), NuGet-ready project tidy-up
1 parent 76ab43d commit b4d0d82

File tree

11 files changed

+526
-509
lines changed

11 files changed

+526
-509
lines changed

QueryWatch_CLI_Refactor.zip

9.58 KB
Binary file not shown.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#nullable enable
2+
using KeelMatrix.QueryWatch.Cli.Model;
3+
4+
namespace KeelMatrix.QueryWatch.Cli.Core {
5+
internal sealed class Aggregated {
6+
public string Schema { get; private set; } = "1.0.0";
7+
public DateTimeOffset StartedAt { get; private set; }
8+
public DateTimeOffset StoppedAt { get; private set; }
9+
public int TotalQueries { get; private set; }
10+
public double TotalDurationMs { get; private set; }
11+
public double AverageDurationMs { get; private set; }
12+
public int SampledEventsCount => Events.Count;
13+
public int FileCount { get; private set; }
14+
public List<EventSample> Events { get; } = new();
15+
16+
public static Aggregated From(IEnumerable<Summary> summaries) {
17+
var agg = new Aggregated();
18+
agg.FileCount = 0;
19+
var haveTimes = false;
20+
var totalDurMs = 0.0;
21+
var totalQueries = 0;
22+
23+
foreach (var s in summaries) {
24+
agg.FileCount++;
25+
agg.Schema = s.Schema;
26+
if (!haveTimes) { agg.StartedAt = s.StartedAt; agg.StoppedAt = s.StoppedAt; haveTimes = true; }
27+
else {
28+
if (s.StartedAt < agg.StartedAt) agg.StartedAt = s.StartedAt;
29+
if (s.StoppedAt > agg.StoppedAt) agg.StoppedAt = s.StoppedAt;
30+
}
31+
32+
totalQueries += s.TotalQueries;
33+
totalDurMs += s.TotalDurationMs;
34+
if (s.Events is not null && s.Events.Count > 0) {
35+
agg.Events.AddRange(s.Events);
36+
}
37+
}
38+
39+
agg.TotalQueries = totalQueries;
40+
agg.TotalDurationMs = totalDurMs;
41+
agg.AverageDurationMs = totalQueries == 0 ? 0.0 : (totalDurMs / totalQueries);
42+
agg.Events.Sort((a, b) => b.DurationMs.CompareTo(a.DurationMs));
43+
return agg;
44+
}
45+
46+
public Summary ToSummary() => new Summary {
47+
Schema = Schema,
48+
StartedAt = StartedAt,
49+
StoppedAt = StoppedAt,
50+
TotalQueries = TotalQueries,
51+
TotalDurationMs = TotalDurationMs,
52+
AverageDurationMs = AverageDurationMs,
53+
Events = Events.ToList(),
54+
Meta = new Dictionary<string, string> { { "aggregatedFromFiles", FileCount.ToString() } }
55+
};
56+
}
57+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#nullable enable
2+
namespace KeelMatrix.QueryWatch.Cli.Core {
3+
internal static class ExitCodes {
4+
public const int Ok = 0;
5+
public const int InvalidArguments = 1;
6+
public const int InputFileNotFound = 2;
7+
public const int JsonParseError = 3;
8+
public const int BudgetExceeded = 4;
9+
public const int BaselineRegression = 5;
10+
}
11+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#nullable enable
2+
using System.Text.RegularExpressions;
3+
4+
namespace KeelMatrix.QueryWatch.Cli.Core {
5+
internal sealed class PatternBudget {
6+
public required Regex Regex { get; init; }
7+
public required int MaxCount { get; init; }
8+
public required string RawPattern { get; init; }
9+
10+
public static bool TryParse(string spec, out PatternBudget? budget, out string? error) {
11+
budget = null; error = null;
12+
if (string.IsNullOrWhiteSpace(spec)) { error = "Empty spec"; return false; }
13+
var idx = spec.LastIndexOf('=');
14+
if (idx <= 0 || idx == spec.Length - 1) { error = "Expected '<pattern>=<max>'"; return false; }
15+
var pRaw = spec.Substring(0, idx).Trim();
16+
var maxRaw = spec[(idx + 1)..].Trim();
17+
if (!int.TryParse(maxRaw, out var max) || max < 0) { error = "Invalid <max> (must be non-negative integer)"; return false; }
18+
19+
Regex rx;
20+
if (pRaw.StartsWith("regex:", StringComparison.OrdinalIgnoreCase)) {
21+
var body = pRaw.Substring("regex:".Length);
22+
try { rx = new Regex(body, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled); }
23+
catch (Exception ex) { error = "Invalid regex: " + ex.Message; return false; }
24+
}
25+
else {
26+
// Treat as wildcard: escape then replace * -> .*, ? -> .
27+
var escaped = Regex.Escape(pRaw).Replace(@"\*", ".*").Replace(@"\?", ".");
28+
rx = new Regex("^" + escaped + "$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled);
29+
}
30+
31+
budget = new PatternBudget { Regex = rx, MaxCount = max, RawPattern = pRaw };
32+
return true;
33+
}
34+
}
35+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#nullable enable
2+
using System.Text.Json;
3+
using KeelMatrix.QueryWatch.Cli.Model;
4+
5+
namespace KeelMatrix.QueryWatch.Cli.IO {
6+
internal static class SummaryLoader {
7+
public static async Task<IReadOnlyList<Summary>> LoadAsync(IEnumerable<string> paths) {
8+
var found = new List<Summary>();
9+
var notFound = new List<string>();
10+
11+
foreach (var path in paths) {
12+
if (!File.Exists(path)) {
13+
notFound.Add(path);
14+
continue;
15+
}
16+
17+
try {
18+
await using var stream = File.OpenRead(path);
19+
var s = await JsonSerializer.DeserializeAsync<Summary>(stream).ConfigureAwait(false);
20+
if (s is null) throw new InvalidOperationException($"Summary is null: {path}");
21+
found.Add(s);
22+
}
23+
catch (Exception ex) {
24+
throw new JsonException($"Failed to parse JSON '{path}': {ex.Message}", ex);
25+
}
26+
}
27+
28+
if (found.Count == 0) {
29+
var msg = "No input JSON found." + (notFound.Count > 0 ? " Missing: " + string.Join(", ", notFound) : string.Empty);
30+
throw new FileNotFoundException(msg);
31+
}
32+
33+
return found;
34+
}
35+
}
36+
}

tools/KeelMatrix.QueryWatch.Cli/KeelMatrix.QueryWatch.Cli.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
<LangVersion>latest</LangVersion>
88
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
99

10+
<RootNamespace>KeelMatrix.QueryWatch.Cli</RootNamespace>
11+
<AssemblyName>KeelMatrix.QueryWatch.Cli</AssemblyName>
12+
<Deterministic>true</Deterministic>
13+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
14+
1015
<!-- Optional: pack as a dotnet tool later -->
1116
<PackAsTool>true</PackAsTool>
1217
<PackageId>KeelMatrix.QueryWatch.Cli</PackageId>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#nullable enable
2+
using System.Text.Json.Serialization;
3+
4+
namespace KeelMatrix.QueryWatch.Cli.Model {
5+
/// <summary>
6+
/// Compact representation of a QueryWatch report used by the CLI,
7+
/// matching the JSON written by KeelMatrix.QueryWatch.Reporting.QueryWatchJson.
8+
/// </summary>
9+
internal sealed class Summary {
10+
[JsonPropertyName("schema")]
11+
public string Schema { get; set; } = "1.0.0";
12+
13+
[JsonPropertyName("startedAt")]
14+
public DateTimeOffset StartedAt { get; set; }
15+
16+
[JsonPropertyName("stoppedAt")]
17+
public DateTimeOffset StoppedAt { get; set; }
18+
19+
[JsonPropertyName("totalQueries")]
20+
public int TotalQueries { get; set; }
21+
22+
[JsonPropertyName("totalDurationMs")]
23+
public double TotalDurationMs { get; set; }
24+
25+
[JsonPropertyName("averageDurationMs")]
26+
public double AverageDurationMs { get; set; }
27+
28+
[JsonPropertyName("events")]
29+
public List<EventSample> Events { get; set; } = new();
30+
31+
[JsonPropertyName("meta")]
32+
public Dictionary<string, string> Meta { get; set; } = new();
33+
}
34+
35+
internal sealed class EventSample {
36+
[JsonPropertyName("at")]
37+
public DateTimeOffset At { get; set; }
38+
39+
[JsonPropertyName("durationMs")]
40+
public double DurationMs { get; set; }
41+
42+
[JsonPropertyName("text")]
43+
public string Text { get; set; } = string.Empty;
44+
}
45+
}

0 commit comments

Comments
 (0)