From 32e4ecc827af9d79fcec5f29217e478634ed62e2 Mon Sep 17 00:00:00 2001 From: KeelMatrix Date: Sun, 29 Mar 2026 11:21:33 +0700 Subject: [PATCH 1/4] Add CLI telemetry management commands --- README.md | 11 + .../BaselineToleranceTests.cs | 18 +- .../BaselineWriteTests.cs | 6 +- .../CliRunner.cs | 17 +- .../EndToEndLibraryCliTests.cs | 48 +- .../InvalidInputAndBudgetTests.cs | 9 +- .../MultiFileAggregationTests.cs | 18 +- .../PatternBudgetTests.cs | 12 +- .../PatternBudgetWildcardsAndRegexTests.cs | 39 +- .../TelemetryCommandTests.cs | 309 ++++++++++++ .../Options/CliCommandParser.cs | 42 ++ .../Options/CliSpec.cs | 72 ++- .../Options/TelemetryCommandLineOptions.cs | 73 +++ tools/KeelMatrix.QueryWatch.Cli/Program.cs | 10 +- .../QueryWatchCliTelemetry.cs | 19 +- tools/KeelMatrix.QueryWatch.Cli/README.md | 20 + .../Telemetry/RepoRootResolver.cs | 73 +++ .../Telemetry/TelemetryCommandHandler.cs | 458 ++++++++++++++++++ 18 files changed, 1186 insertions(+), 68 deletions(-) create mode 100644 tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs diff --git a/README.md b/README.md index cebf15b..dd1a2e5 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,16 @@ If a summary is top-N sampled, budgets are evaluated only over those captured ev ``` +Usage: + qwatch --input file.json [options] + qwatch telemetry [options] + +Commands: +telemetry status [--json] Show effective telemetry state for the current repo. +telemetry disable Write repo-local keelmatrix.telemetry.json with disabled=true. +telemetry enable Remove or neutralize qwatch-managed repo-local telemetry opt-out. + +Options: --input Input JSON summary file. (repeatable) --max-queries N Fail if total query count exceeds N. --max-average-ms N Fail if average duration exceeds N ms. @@ -232,6 +242,7 @@ Multi-file support: - repeat `--input` to aggregate summaries from multiple test projects - compare current results against a baseline summary - write GitHub Actions step summaries automatically when running in CI +- inspect or manage repo-local telemetry with `qwatch telemetry status|disable|enable` ## Troubleshooting diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs index 47b0fe7..66c56a4 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs @@ -11,9 +11,12 @@ public void Within_Tolerance_ExitCode_Ok() { string baseline = Path.Combine(AppContext.BaseDirectory, "Fixtures", "baseline.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", current, - "--baseline", baseline, - "--baseline-allow-percent", "10" + "--input", + current, + "--baseline", + baseline, + "--baseline-allow-percent", + "10" ]); _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); @@ -25,9 +28,12 @@ public void Beyond_Tolerance_ExitCode_BaselineRegression() { string baseline = Path.Combine(AppContext.BaseDirectory, "Fixtures", "baseline.json"); (int code, string _, string? stderr) = CliRunner.Run([ - "--input", current, - "--baseline", baseline, - "--baseline-allow-percent", "10" + "--input", + current, + "--baseline", + baseline, + "--baseline-allow-percent", + "10" ]); _ = code.Should().Be(5); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs index 8c28f3a..ac1334b 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs @@ -15,8 +15,10 @@ public void WriteBaseline_Creates_File_And_Prints_Message() { try { // Act (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--baseline", baselinePath, + "--input", + f, + "--baseline", + baselinePath, "--write-baseline" ]); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs index 8ce0051..95e4454 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs @@ -9,7 +9,10 @@ internal static class CliRunner { private static string? _publishedDir; private static string? _publishedDll; - public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (string Key, string Value)[]? env = null) { + public static (int ExitCode, string StdOut, string StdErr) Run( + string[] args, + (string Key, string? Value)[]? env = null, + string? workingDirectory = null) { string repoRoot = FindRepoRoot(); EnsurePublished(repoRoot, out string? dllPath, out string? workDir); @@ -18,7 +21,7 @@ public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (s RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, - WorkingDirectory = workDir + WorkingDirectory = workingDirectory ?? workDir }; // Run the published app: dotnet -- @@ -26,8 +29,12 @@ public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (s foreach (string a in args) psi.ArgumentList.Add(a); if (env is not null) { - foreach (var (k, v) in env) - psi.Environment[k] = v; + foreach (var (k, v) in env) { + if (v is null) + _ = psi.Environment.Remove(k); + else + psi.Environment[k] = v; + } } using var proc = Process.Start(psi)!; @@ -126,5 +133,7 @@ private static string FindRepoRoot() { } return AppContext.BaseDirectory; } + + internal static string GetRepoRoot() => FindRepoRoot(); } } diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs index d8f6e28..76a1640 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs @@ -35,14 +35,18 @@ public void LibraryJson_MaxQueries_Pass_Then_Fail() { var json = CreateLibraryJson(totalEvents: 2, sampleTop: 10, out _); (int okCode, string? okOut, string? okErr) = CliRunner.Run([ - "--input", json, - "--max-queries", "3" + "--input", + json, + "--max-queries", + "3" ]); _ = okCode.Should().Be(0, okOut + Environment.NewLine + okErr); (int failCode, string _, string? failErr) = CliRunner.Run([ - "--input", json, - "--max-queries", "1" + "--input", + json, + "--max-queries", + "1" ]); _ = failCode.Should().Be(4); _ = failErr.Should().Contain("Max queries exceeded"); @@ -54,15 +58,19 @@ public void LibraryJson_PatternBudget_Pass_Then_Fail() { // Allow exactly the predictable match (int okCode, string? okOut, string? okErr) = CliRunner.Run([ - "--input", json, - "--budget", "SELECT * FROM Users*=1" + "--input", + json, + "--budget", + "SELECT * FROM Users*=1" ]); _ = okCode.Should().Be(0, okOut + Environment.NewLine + okErr); // Now disallow it (int badCode, string _, string? badErr) = CliRunner.Run([ - "--input", json, - "--budget", "SELECT * FROM Users*=0" + "--input", + json, + "--budget", + "SELECT * FROM Users*=0" ]); _ = badCode.Should().Be(4); _ = badErr.Should().Contain("Budget violations"); @@ -74,8 +82,10 @@ public void Baseline_Write_Then_Compare_With_Tolerance() { var baselinePath = Path.Combine(Path.GetTempPath(), "qwatch-baseline-" + Guid.NewGuid().ToString("N"), "baseline.json"); (int writeCode, string? writeOut, string? writeErr) = CliRunner.Run([ - "--input", current1, - "--baseline", baselinePath, + "--input", + current1, + "--baseline", + baselinePath, "--write-baseline" ]); _ = writeCode.Should().Be(0, writeOut + Environment.NewLine + writeErr); @@ -85,17 +95,23 @@ public void Baseline_Write_Then_Compare_With_Tolerance() { // Generous tolerance -> pass (int passCode, string? passOut, string? passErr) = CliRunner.Run([ - "--input", current2, - "--baseline", baselinePath, - "--baseline-allow-percent", "80" + "--input", + current2, + "--baseline", + baselinePath, + "--baseline-allow-percent", + "80" ]); _ = passCode.Should().Be(0, passOut + Environment.NewLine + passErr); // Tight tolerance -> fail with baseline regression code (int failCode, string _, string? failErr) = CliRunner.Run([ - "--input", current2, - "--baseline", baselinePath, - "--baseline-allow-percent", "10" + "--input", + current2, + "--baseline", + baselinePath, + "--baseline-allow-percent", + "10" ]); _ = failCode.Should().Be(5); _ = failErr.Should().Contain("Baseline regressions"); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs index d5c311a..f98a2b0 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs @@ -45,7 +45,8 @@ public void Missing_Budget_Value_Shows_Parse_Error() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, + "--input", + f, "--budget" // missing value ]); @@ -58,8 +59,10 @@ public void Invalid_Budget_Spec_Returns_InvalidArguments() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "not-a-valid-spec" // lacks = and max + "--input", + f, + "--budget", + "not-a-valid-spec" // lacks = and max ]); _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); // ExitCodes.InvalidArguments diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs index 4b8bccd..5e2aa81 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs @@ -11,18 +11,24 @@ public void Aggregates_Across_Multiple_Files_And_Respects_MaxQueries() { string f2 = Path.Combine(AppContext.BaseDirectory, "Fixtures", "agg_b.json"); (int exitOk, string? stdoutOk, string? stderrOk) = CliRunner.Run([ - "--input", f1, - "--input", f2, - "--max-queries", "5" + "--input", + f1, + "--input", + f2, + "--max-queries", + "5" ]); _ = exitOk.Should().Be(0, stdoutOk + Environment.NewLine + stderrOk); _ = stdoutOk.Should().Contain("files 2"); _ = stdoutOk.Should().Contain("Queries: 5"); (int exitFail, string _, string? stderrFail) = CliRunner.Run([ - "--input", f1, - "--input", f2, - "--max-queries", "4" + "--input", + f1, + "--input", + f2, + "--max-queries", + "4" ]); _ = exitFail.Should().Be(4); _ = stderrFail.Should().Contain("Budget violations:"); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs index 338aeeb..d86b7d2 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs @@ -10,8 +10,10 @@ public void Exceeds_Pattern_Budget_Returns_BudgetExceeded() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run( [ - "--input", f, - "--budget", "SELECT * FROM Users*=1" + "--input", + f, + "--budget", + "SELECT * FROM Users*=1" ]); // For pattern budgets the CLI returns 4 when the count exceeds the budget. @@ -26,8 +28,10 @@ public void Meets_Pattern_Budget_Returns_Ok() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run( [ - "--input", f, - "--budget", "SELECT * FROM Users*=2" + "--input", + f, + "--budget", + "SELECT * FROM Users*=2" ]); _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs index a4acfde..805ce75 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs @@ -11,15 +11,19 @@ public void Wildcard_QuestionMark_Matches_Single_Character_And_Ignores_Case() { // Lowercase pattern, '?' for one char in "Users", '*' for the rest of the line (int ok, string? stdoutOk, string? stderrOk) = CliRunner.Run([ - "--input", f, - "--budget", "select * from user?* = 2".Replace(" ", "") // avoid quoting/space headaches + "--input", + f, + "--budget", + "select * from user?* = 2".Replace(" ", "") // avoid quoting/space headaches ]); _ = ok.Should().Be(0, stdoutOk + Environment.NewLine + stderrOk); // Overly strict budget should fail (2 matches > 1 allowed) (int fail, string? stdoutFail, string? stderrFail) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT*FROM*User?*=1" + "--input", + f, + "--budget", + "SELECT*FROM*User?*=1" ]); _ = fail.Should().Be(4, stdoutFail + Environment.NewLine + stderrFail); } @@ -30,15 +34,19 @@ public void Regex_Budget_Is_Case_Insensitive_And_Anchored_From_Start() { // Should match both SELECTs in pattern.json (int ok, string? so1, string? se1) = CliRunner.Run([ - "--input", f, - "--budget", @"regex:^select\s+\*\s+from\s+users\b.*=2" + "--input", + f, + "--budget", + @"regex:^select\s+\*\s+from\s+users\b.*=2" ]); _ = ok.Should().Be(0, so1 + Environment.NewLine + se1); // Only allow 1 -> should fail because there are 2 matches (int bad, string? so2, string? se2) = CliRunner.Run([ - "--input", f, - "--budget", @"regex:^SELECT\s+\*\s+FROM\s+Users\b.*=1" + "--input", + f, + "--budget", + @"regex:^SELECT\s+\*\s+FROM\s+Users\b.*=1" ]); _ = bad.Should().Be(4, so2 + Environment.NewLine + se2); } @@ -58,8 +66,10 @@ public void Zero_Allowed_Count_Fails_When_There_Are_Matches() { string f = System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT * FROM Users*=0" + "--input", + f, + "--budget", + "SELECT * FROM Users*=0" ]); _ = code.Should().Be(4, stdout + Environment.NewLine + stderr); // BudgetExceeded } @@ -69,9 +79,12 @@ public void Multiple_Budgets_One_Over_Still_Triggers_Failure() { string f = System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT * FROM Users*=1", // over (2 > 1) - "--budget", "INSERT INTO Users*=2" // under (1 <= 2) + "--input", + f, + "--budget", + "SELECT * FROM Users*=1", // over (2 > 1) + "--budget", + "INSERT INTO Users*=2" // under (1 <= 2) ]); _ = code.Should().Be(4, stdout + Environment.NewLine + stderr); } diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs new file mode 100644 index 0000000..b16adf7 --- /dev/null +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs @@ -0,0 +1,309 @@ +// Copyright (c) KeelMatrix + +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace KeelMatrix.QueryWatch.Cli.IntegrationTests { + [CollectionDefinition(CollectionName)] + public sealed class TelemetryCommandCollection : ICollectionFixture { + public const string CollectionName = "Telemetry CLI"; + } + + public sealed class TelemetryTestCleanupFixture : IDisposable { + public void Dispose() { + CleanupTempDirectories("qwatch-telemetry-*"); + CleanupTempDirectories("qwatch-no-repo-*"); + } + + private static void CleanupTempDirectories(string searchPattern) { + try { + foreach (DirectoryInfo directory in new DirectoryInfo(Path.GetTempPath()).EnumerateDirectories(searchPattern)) { + try { + directory.Delete(recursive: true); + } + catch { + // Best-effort cleanup after the collection finishes. + } + } + } + catch { + // Ignore temp-folder enumeration failures in test cleanup. + } + } + } + + [Collection(TelemetryCommandCollection.CollectionName)] + public sealed class TelemetryCommandTests { + [Fact] + public void Telemetry_Status_Shows_Enabled_When_No_Overrides_Exist() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: enabled"); + _ = stdout.Should().Contain("Source: none"); + _ = stdout.Should().Contain($"Repo: {repo.Root}"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_Process_Environment_Disables() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: [.. RepoScope.TelemetryEnvironment, ("KEELMATRIX_NO_TELEMETRY", "1")], + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: process environment"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + _ = stdout.Should().Contain("Scope: process-level"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_Repository_Config_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: keelmatrix.telemetry.json"); + _ = stdout.Should().Contain(Path.Combine(repo.Root, "keelmatrix.telemetry.json")); + _ = stdout.Should().Contain("Scope: repo-local"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_DotEnvLocal_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env.local"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: .env.local"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_DotEnv_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: .env"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + } + + [Fact] + public void Telemetry_Status_Shows_Process_Override_When_Process_Environment_Masks_Repo_Config() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: [.. RepoScope.TelemetryEnvironment, ("KEELMATRIX_NO_TELEMETRY", "0")], + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: enabled"); + _ = stdout.Should().Contain("Source: process environment"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + _ = stdout.Should().Contain("Note: repo-local config is ignored while this process-level override is present"); + } + + [Fact] + public void Telemetry_Status_Json_Uses_Status_Model() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env.local"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + _ = doc.RootElement.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); + _ = doc.RootElement.GetProperty("winningSource").GetString().Should().Be(".env.local"); + _ = doc.RootElement.GetProperty("winningPathOrVariable").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); + _ = doc.RootElement.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); + } + + [Fact] + public void Telemetry_Disable_Writes_Repository_Config() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + string configPath = Path.Combine(repo.Root, "keelmatrix.telemetry.json"); + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); + _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeTrue(); + } + + [Fact] + public void Telemetry_Enable_Removes_Qwatch_Managed_Config_File() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeFalse(); + _ = stdout.Should().Contain("Repo-local telemetry opt-out removed"); + _ = stdout.Should().Contain("Telemetry: enabled"); + } + + [Fact] + public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Content() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); + _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeFalse(); + _ = doc.RootElement.GetProperty("channel").GetString().Should().Be("dev"); + } + + [Fact] + public void Telemetry_Commands_Do_Not_Call_TrackActivation() { + using RepoScope repo = RepoScope.Create(); + string sentinelPath = Path.Combine(repo.Root, "activation-sentinel.txt"); + + (int telemetryCode, string telemetryOut, string telemetryErr) = CliRunner.Run( + ["telemetry", "status"], + env: [.. RepoScope.TelemetryEnvironment, ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", sentinelPath)], + workingDirectory: repo.WorkingDirectory); + + _ = telemetryCode.Should().Be(0, telemetryOut + Environment.NewLine + telemetryErr); + _ = File.Exists(sentinelPath).Should().BeFalse(); + + string inputPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "current_ok.json"); + (int normalCode, string normalOut, string normalErr) = CliRunner.Run( + ["--input", inputPath], + env: [.. RepoScope.TelemetryEnvironment, ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", sentinelPath)]); + + _ = normalCode.Should().Be(0, normalOut + Environment.NewLine + normalErr); + _ = File.Exists(sentinelPath).Should().BeTrue(); + } + + [Fact] + public void Telemetry_Help_And_Flag_Docs_Include_Command_Group() { + (int helpCode, string helpOut, string helpErr) = CliRunner.Run(["--help"]); + _ = helpCode.Should().Be(0, helpOut + Environment.NewLine + helpErr); + _ = helpOut.Should().Contain("qwatch telemetry [options]"); + _ = helpOut.Should().Contain("telemetry status [--json]"); + + (int flagsCode, string flagsOut, string flagsErr) = CliRunner.Run(["--print-flags-md"]); + _ = flagsCode.Should().Be(0, flagsOut + Environment.NewLine + flagsErr); + _ = flagsOut.Should().Contain("qwatch telemetry [options]"); + _ = flagsOut.Should().Contain("telemetry enable"); + } + + [Fact] + public void Telemetry_Status_Fails_When_No_Repository_Root_Is_Found() { + string workingDirectory = Path.Combine(Path.GetTempPath(), "qwatch-no-repo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + + try { + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.EmptyTelemetryEnvironment, + workingDirectory: workingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stderr.Should().Contain("No repository root could be resolved"); + } + finally { + try { Directory.Delete(workingDirectory, recursive: true); } catch { /* best-effort */ } + } + } + + private sealed class RepoScope : IDisposable { + internal static readonly (string Key, string? Value)[] EmptyTelemetryEnvironment = [ + ("KEELMATRIX_NO_TELEMETRY", null), + ("DOTNET_CLI_TELEMETRY_OPTOUT", null), + ("DO_NOT_TRACK", null), + ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", null) + ]; + + private RepoScope(string root) { + Root = root; + WorkingDirectory = Path.Combine(root, "src", "tests"); + Directory.CreateDirectory(Path.Combine(root, ".git")); + Directory.CreateDirectory(WorkingDirectory); + } + + internal string Root { get; } + internal string WorkingDirectory { get; } + internal static (string Key, string? Value)[] TelemetryEnvironment => EmptyTelemetryEnvironment; + + internal static RepoScope Create() { + string root = Path.Combine(Path.GetTempPath(), "qwatch-telemetry-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return new RepoScope(root); + } + + internal string WriteFile(string content, params string[] segments) { + string path = Path.Combine([Root, .. segments]); + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, content); + return path; + } + + public void Dispose() { + try { + Directory.Delete(Root, recursive: true); + } + catch { + // Best-effort cleanup for temp test repos. + } + } + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs new file mode 100644 index 0000000..9aa5c85 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs @@ -0,0 +1,42 @@ +// Copyright (c) KeelMatrix + +namespace KeelMatrix.QueryWatch.Cli.Options { + internal static class CliCommandParser { + public static RootParseResult Parse(string[] args) { + if (args.Length > 0 && string.Equals(args[0], "telemetry", StringComparison.OrdinalIgnoreCase)) { + return RootParseResult.FromTelemetry(TelemetryCommandLineOptions.Parse(args[1..])); + } + + return RootParseResult.FromAnalysis(CommandLineOptions.Parse(args)); + } + } + + internal readonly record struct RootParseResult( + bool Success, + CommandLineOptions? Options, + TelemetryCommandLineOptions? TelemetryOptions, + string? ErrorMessage, + bool ShowHelp, + string HelpText) { + + public static RootParseResult FromAnalysis(ParseResult parsed) { + return new RootParseResult( + parsed.Success, + parsed.Success ? parsed.Options : null, + null, + parsed.ErrorMessage, + parsed.ShowHelp, + CommandLineOptions.HelpText); + } + + public static RootParseResult FromTelemetry(TelemetryParseResult parsed) { + return new RootParseResult( + parsed.Success, + null, + parsed.Success ? parsed.Options : null, + parsed.ErrorMessage, + parsed.ShowHelp, + TelemetryCommandLineOptions.HelpText); + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs index e617d4a..8b80ef1 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs @@ -4,6 +4,7 @@ namespace KeelMatrix.QueryWatch.Cli.Options { internal sealed record CliOption(string Flag, string? ValueSyntax, string Description, string? Notes = null, bool Repeatable = false); + internal sealed record CliCommand(string Command, string Description); internal static class CliSpec { public static readonly CliOption[] Options = [ @@ -20,27 +21,32 @@ internal static class CliSpec { new CliOption("--help", null, "Show this help.") ]; + public static readonly CliCommand[] TelemetryCommands = [ + new CliCommand("telemetry status [--json]", "Show effective telemetry state for the current repo."), + new CliCommand("telemetry disable", "Write repo-local keelmatrix.telemetry.json with disabled=true."), + new CliCommand("telemetry enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out.") + ]; + public static string BuildHelpText() { // column widths const int leftWidth = 30; StringBuilder sb = new(); _ = sb.AppendLine("QueryWatch CLI"); _ = sb.AppendLine(); - _ = sb.AppendLine("Usage: qwatch --input file.json [options]"); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch --input file.json [options]"); + _ = sb.AppendLine(" qwatch telemetry [options]"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + foreach (CliCommand command in TelemetryCommands) { + _ = AppendAlignedLine(sb, leftWidth, command.Command, command.Description); + } _ = sb.AppendLine(); _ = sb.AppendLine("Options:"); foreach (CliOption o in Options) { var left = o.Flag + (o.ValueSyntax is not null ? " " + o.ValueSyntax : string.Empty); var repeat = o.Repeatable ? " (repeatable)" : string.Empty; - _ = sb.Append(" "); - if (left.Length >= leftWidth) { - _ = sb.AppendLine(left); - _ = sb.Append(' ', leftWidth); - } - else { - _ = sb.Append(left.PadRight(leftWidth)); - } - _ = sb.AppendLine(o.Description + repeat); + _ = AppendAlignedLine(sb, leftWidth, left, o.Description + repeat); if (!string.IsNullOrWhiteSpace(o.Notes)) { _ = sb.Append(' ', 2 + leftWidth); _ = sb.AppendLine(o.Notes); @@ -49,9 +55,41 @@ public static string BuildHelpText() { return sb.ToString(); } + public static string BuildTelemetryHelpText() { + const int leftWidth = 28; + StringBuilder sb = new(); + _ = sb.AppendLine("QueryWatch CLI telemetry"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch telemetry status [--json]"); + _ = sb.AppendLine(" qwatch telemetry disable"); + _ = sb.AppendLine(" qwatch telemetry enable"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + _ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state for the current repo."); + _ = AppendAlignedLine(sb, leftWidth, "disable", "Write repo-local keelmatrix.telemetry.json with disabled=true."); + _ = AppendAlignedLine(sb, leftWidth, "enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out."); + _ = sb.AppendLine(); + _ = sb.AppendLine("Options:"); + _ = AppendAlignedLine(sb, leftWidth, "--json", "Output telemetry status as JSON."); + _ = AppendAlignedLine(sb, leftWidth, "--help", "Show this help."); + return sb.ToString(); + } + public static string BuildReadmeFlagsMarkdown() { StringBuilder sb = new(); _ = sb.AppendLine("```"); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch --input file.json [options]"); + _ = sb.AppendLine(" qwatch telemetry [options]"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + foreach (CliCommand command in TelemetryCommands) { + var left = command.Command.PadRight(28); + _ = sb.AppendLine($"{left} {command.Description}"); + } + _ = sb.AppendLine(); + _ = sb.AppendLine("Options:"); foreach (CliOption o in Options) { var left = (o.Flag + (o.ValueSyntax is not null ? " " + o.ValueSyntax : string.Empty)).PadRight(28); var repeat = o.Repeatable ? " (repeatable)" : string.Empty; @@ -63,5 +101,19 @@ public static string BuildReadmeFlagsMarkdown() { _ = sb.AppendLine("```"); return sb.ToString(); } + + private static StringBuilder AppendAlignedLine(StringBuilder sb, int leftWidth, string left, string right) { + _ = sb.Append(" "); + if (left.Length >= leftWidth) { + _ = sb.AppendLine(left); + _ = sb.Append(' ', leftWidth); + } + else { + _ = sb.Append(left.PadRight(leftWidth)); + } + + _ = sb.AppendLine(right); + return sb; + } } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs new file mode 100644 index 0000000..daab124 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs @@ -0,0 +1,73 @@ +// Copyright (c) KeelMatrix + +namespace KeelMatrix.QueryWatch.Cli.Options { + internal enum TelemetryCommandKind { + Status, + Disable, + Enable + } + + internal sealed class TelemetryCommandLineOptions { + public TelemetryCommandKind Command { get; init; } + public bool Json { get; init; } + public bool ShowHelp { get; init; } + + public static TelemetryParseResult Parse(string[] args) { + if (HasHelp(args)) + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { ShowHelp = true }); + + if (args.Length == 0) + return TelemetryParseResult.Error("Missing telemetry command. Use 'qwatch telemetry --help' to see available commands."); + + if (string.Equals(args[0], "status", StringComparison.OrdinalIgnoreCase)) + return ParseStatus(args[1..]); + + if (string.Equals(args[0], "disable", StringComparison.OrdinalIgnoreCase)) + return ParseNoOptionCommand(TelemetryCommandKind.Disable, "disable", args[1..]); + + if (string.Equals(args[0], "enable", StringComparison.OrdinalIgnoreCase)) + return ParseNoOptionCommand(TelemetryCommandKind.Enable, "enable", args[1..]); + + return TelemetryParseResult.Error($"Unknown telemetry command: {args[0]}"); + } + + public static string HelpText => CliSpec.BuildTelemetryHelpText(); + + private static TelemetryParseResult ParseStatus(string[] args) { + bool json = false; + + foreach (string arg in args) { + if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase)) { + json = true; + continue; + } + + return TelemetryParseResult.Error($"Unknown telemetry status argument: {arg}"); + } + + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { + Command = TelemetryCommandKind.Status, + Json = json + }); + } + + private static TelemetryParseResult ParseNoOptionCommand(TelemetryCommandKind kind, string verb, string[] args) { + if (args.Length > 0) + return TelemetryParseResult.Error($"Unknown telemetry {verb} argument: {args[0]}"); + + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { Command = kind }); + } + + private static bool HasHelp(string[] args) { + return args.Any(static arg => + string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "/?", StringComparison.OrdinalIgnoreCase)); + } + } + + internal readonly record struct TelemetryParseResult(bool Success, TelemetryCommandLineOptions Options, string? ErrorMessage, bool ShowHelp) { + public static TelemetryParseResult Successful(TelemetryCommandLineOptions opts) => new(true, opts, null, opts.ShowHelp); + public static TelemetryParseResult Error(string error) => new(false, new TelemetryCommandLineOptions(), error, true); + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Program.cs b/tools/KeelMatrix.QueryWatch.Cli/Program.cs index debc4cf..48ae92c 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Program.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Program.cs @@ -2,6 +2,7 @@ using KeelMatrix.QueryWatch.Cli.Core; using KeelMatrix.QueryWatch.Cli.Options; +using KeelMatrix.QueryWatch.Cli.Telemetry; namespace KeelMatrix.QueryWatch.Cli { internal static class Program { @@ -18,13 +19,13 @@ private static async Task Main(string[] args) { return ExitCodes.Ok; } - ParseResult parsed = CommandLineOptions.Parse(args); + RootParseResult parsed = CliCommandParser.Parse(args); // 2) If the caller asked for help, always print it and return: // - 0 when parse succeeded (pure help) // - 1 when parse failed (help + error) if (parsed.ShowHelp) { - Console.WriteLine(CommandLineOptions.HelpText); + Console.WriteLine(parsed.HelpText); if (!parsed.Success && !string.IsNullOrEmpty(parsed.ErrorMessage)) await Console.Error.WriteLineAsync(parsed.ErrorMessage); return parsed.Success ? ExitCodes.Ok : ExitCodes.InvalidArguments; @@ -37,9 +38,12 @@ private static async Task Main(string[] args) { return ExitCodes.InvalidArguments; } + if (parsed.TelemetryOptions is not null) + return await TelemetryCommandHandler.ExecuteAsync(parsed.TelemetryOptions).ConfigureAwait(false); + // 4) Normal execution QueryWatchCliTelemetry.TrackActivation(); - return await Runner.ExecuteAsync(parsed.Options); + return await Runner.ExecuteAsync(parsed.Options!).ConfigureAwait(false); } } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs b/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs index f5929e1..4d8db60 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs @@ -4,8 +4,25 @@ namespace KeelMatrix.QueryWatch.Cli { internal static class QueryWatchCliTelemetry { + private const string ActivationSentinelPathEnvVar = "QWATCH_CLI_TRACK_ACTIVATION_SENTINEL"; private static readonly Client Client = new("qwatchCLI", typeof(Program)); - internal static void TrackActivation() => Client.TrackActivation(); + internal static void TrackActivation() { + TryWriteActivationSentinel(); + Client.TrackActivation(); + } + + private static void TryWriteActivationSentinel() { + try { + string? path = Environment.GetEnvironmentVariable(ActivationSentinelPathEnvVar); + if (string.IsNullOrWhiteSpace(path)) + return; + + File.AppendAllText(path, "activated" + Environment.NewLine); + } + catch { + // Test-only best effort; never block telemetry or CLI execution. + } + } } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/README.md b/tools/KeelMatrix.QueryWatch.Cli/README.md index 6199ef2..3f2a4e8 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/README.md +++ b/tools/KeelMatrix.QueryWatch.Cli/README.md @@ -31,6 +31,24 @@ Show help: qwatch --help ``` +Inspect telemetry state for the current repo: + +```bash +qwatch telemetry status +``` + +Disable telemetry for the current repo: + +```bash +qwatch telemetry disable +``` + +Re-enable telemetry for the current repo: + +```bash +qwatch telemetry enable +``` + Fail if total queries exceed 50: ```bash @@ -137,6 +155,8 @@ If you only need the file format in another tool, see the contracts package: `qwatch` sends a minimal anonymous telemetry activation event on normal CLI execution. +Telemetry management commands do not emit telemetry. Use `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` to inspect or manage repo-local telemetry behavior without introducing a second config model. + It does not send heartbeat events. Reason: `qwatch` is typically a short-lived CI/local tool, so weekly heartbeat would mostly reflect retained pipeline wiring rather than meaningful interactive product usage. See: diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs new file mode 100644 index 0000000..61fa7f8 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs @@ -0,0 +1,73 @@ +// Copyright (c) KeelMatrix + +namespace KeelMatrix.QueryWatch.Cli.Telemetry { + // Modified copy of KeelMatrix.Telemetry ProjectIdentity/GitDiscovery repo-root walking, + // narrowed to the current working directory for qwatch repo-scoped telemetry commands. + internal static class RepoRootResolver { + private const int MaxUpwardSteps = 32; + + internal static bool TryFindRepositoryRootFromCurrentDirectory(out string repositoryRoot) { + repositoryRoot = string.Empty; + + string? current = SafeGetFullPath(Environment.CurrentDirectory); + if (string.IsNullOrEmpty(current)) + return false; + + string? fallbackRepositoryRoot = null; + + for (int i = 0; i <= MaxUpwardSteps && !string.IsNullOrEmpty(current); i++) { + if (HasGitRepositoryRoot(current)) { + repositoryRoot = current; + return true; + } + + if (fallbackRepositoryRoot is null && LooksLikeNonGitRepositoryRoot(current)) + fallbackRepositoryRoot = current; + + current = SafeGetParentDirectory(current); + } + + if (!string.IsNullOrEmpty(fallbackRepositoryRoot)) { + repositoryRoot = fallbackRepositoryRoot; + return true; + } + + return false; + } + + private static bool HasGitRepositoryRoot(string dir) { + try { + string dotGitPath = Path.Combine(dir, ".git"); + return Directory.Exists(dotGitPath) || File.Exists(dotGitPath); + } + catch { + return false; + } + } + + private static bool LooksLikeNonGitRepositoryRoot(string dir) { + try { + return File.Exists(Path.Combine(dir, "global.json")) + || File.Exists(Path.Combine(dir, "Directory.Build.props")) + || File.Exists(Path.Combine(dir, "Directory.Build.targets")) + || File.Exists(Path.Combine(dir, "NuGet.config")); + } + catch { + return false; + } + } + + private static string SafeGetFullPath(string path) { + try { return Path.GetFullPath(path); } catch { return string.Empty; } + } + + private static string? SafeGetParentDirectory(string path) { + try { + return Directory.GetParent(path)?.FullName; + } + catch { + return null; + } + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs new file mode 100644 index 0000000..e25ef1c --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs @@ -0,0 +1,458 @@ +// Copyright (c) KeelMatrix + +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using KeelMatrix.QueryWatch.Cli.Core; +using KeelMatrix.QueryWatch.Cli.Options; + +namespace KeelMatrix.QueryWatch.Cli.Telemetry { + internal static class TelemetryCommandHandler { + // Modified copy of KeelMatrix.Telemetry TelemetryDisableResolver precedence and file/env parsing + // so the CLI status surface stays aligned with the package's source-of-truth behavior. + private const string RepositoryConfigFileName = "keelmatrix.telemetry.json"; + private const string DotEnvFileName = ".env"; + private const string DotEnvLocalFileName = ".env.local"; + private const int MaxRepositoryConfigBytes = 16 * 1024; + private const int MaxRepositoryEnvFileBytes = 16 * 1024; + + private static readonly string[] OptOutVariableNames = [ + "KEELMATRIX_NO_TELEMETRY", + "DOTNET_CLI_TELEMETRY_OPTOUT", + "DO_NOT_TRACK" + ]; + + private static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public static async Task ExecuteAsync(TelemetryCommandLineOptions options) { + string currentDirectory = Environment.CurrentDirectory; + if (!RepoRootResolver.TryFindRepositoryRootFromCurrentDirectory(out string repoRoot)) { + await Console.Error.WriteLineAsync( + $"No repository root could be resolved from the current working directory '{currentDirectory}'.") + .ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } + + return options.Command switch { + TelemetryCommandKind.Status => await ExecuteStatusAsync(repoRoot, options.Json).ConfigureAwait(false), + TelemetryCommandKind.Disable => await ExecuteDisableAsync(repoRoot).ConfigureAwait(false), + TelemetryCommandKind.Enable => await ExecuteEnableAsync(repoRoot).ConfigureAwait(false), + _ => ExitCodes.InvalidArguments + }; + } + + private static async Task ExecuteStatusAsync(string repoRoot, bool json) { + TelemetryStatusResult status = GetStatus(repoRoot); + + if (json) { + await Console.Out.WriteLineAsync(JsonSerializer.Serialize(status, JsonOptions)).ConfigureAwait(false); + } + else { + await Console.Out.WriteLineAsync(FormatHumanStatus(status)).ConfigureAwait(false); + } + + return ExitCodes.Ok; + } + + private static async Task ExecuteDisableAsync(string repoRoot) { + string path = Path.Combine(repoRoot, RepositoryConfigFileName); + await WriteDisabledConfigAsync(path).ConfigureAwait(false); + + TelemetryStatusResult status = GetStatus(repoRoot); + await Console.Out.WriteLineAsync($"Repo-local telemetry opt-out written: {path}").ConfigureAwait(false); + await WriteEffectiveStatusAsync(status).ConfigureAwait(false); + return ExitCodes.Ok; + } + + private static async Task ExecuteEnableAsync(string repoRoot) { + string path = Path.Combine(repoRoot, RepositoryConfigFileName); + TelemetryConfigMutation mutation = await EnableConfigAsync(path).ConfigureAwait(false); + TelemetryStatusResult status = GetStatus(repoRoot); + + string message = mutation switch { + TelemetryConfigMutation.Removed => $"Repo-local telemetry opt-out removed: {path}", + TelemetryConfigMutation.RewrittenEnabled => $"Repo-local telemetry config set to enabled: {path}", + _ => "No qwatch-managed repo-local telemetry opt-out was found." + }; + + await Console.Out.WriteLineAsync(message).ConfigureAwait(false); + await WriteEffectiveStatusAsync(status).ConfigureAwait(false); + return ExitCodes.Ok; + } + + private static async Task WriteEffectiveStatusAsync(TelemetryStatusResult status) { + await Console.Out.WriteLineAsync($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}").ConfigureAwait(false); + + if (string.Equals(status.WinningSource, "none", StringComparison.Ordinal)) + return; + + if (string.Equals(status.WinningSource, "process environment", StringComparison.Ordinal)) { + await Console.Out + .WriteLineAsync($"Source: process environment variable {status.WinningVariableName}") + .ConfigureAwait(false); + } + else if (!string.IsNullOrEmpty(status.WinningPathOrVariable)) { + await Console.Out + .WriteLineAsync($"Source: {status.WinningSource} ({status.WinningPathOrVariable})") + .ConfigureAwait(false); + } + else { + await Console.Out.WriteLineAsync($"Source: {status.WinningSource}").ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(status.Note)) + await Console.Out.WriteLineAsync($"Note: {status.Note}").ConfigureAwait(false); + } + + private static string FormatHumanStatus(TelemetryStatusResult status) { + StringBuilder sb = new(); + _ = sb.AppendLine($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}"); + _ = sb.AppendLine($"Source: {status.WinningSource}"); + + if (!string.IsNullOrEmpty(status.WinningPathOrVariable)) { + string label = string.Equals(status.Scope, "process-level", StringComparison.Ordinal) + ? "Variable" + : "Path"; + _ = sb.AppendLine($"{label}: {status.WinningPathOrVariable}"); + } + + if (!string.IsNullOrEmpty(status.WinningVariableName) && + !string.Equals(status.Scope, "process-level", StringComparison.Ordinal)) { + _ = sb.AppendLine($"Variable: {status.WinningVariableName}"); + } + + _ = sb.AppendLine($"Scope: {status.Scope}"); + _ = sb.AppendLine($"Repo: {status.RepoRoot}"); + + if (!string.IsNullOrWhiteSpace(status.Note)) + _ = sb.AppendLine($"Note: {status.Note}"); + + return sb.ToString().TrimEnd(); + } + + private static TelemetryStatusResult GetStatus(string repoRoot) { + TelemetryDecision? repositoryDecision = EvaluateRepository(repoRoot); + TelemetryDecision? processDecision = EvaluateProcessEnvironment(); + TelemetryDecision? winningDecision = processDecision ?? repositoryDecision; + + if (winningDecision is null) { + return new TelemetryStatusResult { + IsEnabled = true, + WinningSource = "none", + Scope = "repo-local default", + RepoRoot = repoRoot, + Message = "Telemetry is enabled; no process or repo-local override was found." + }; + } + + string? note = processDecision is not null && repositoryDecision is not null + ? "repo-local config is ignored while this process-level override is present" + : null; + TelemetryDecision resolvedDecision = winningDecision.Value; + + return new TelemetryStatusResult { + IsEnabled = resolvedDecision.IsEnabled, + WinningSource = resolvedDecision.Source, + WinningPathOrVariable = resolvedDecision.PathOrVariable, + WinningVariableName = resolvedDecision.VariableName, + Scope = resolvedDecision.Scope, + RepoRoot = repoRoot, + Message = resolvedDecision.Message, + Note = note + }; + } + + private static TelemetryDecision? EvaluateProcessEnvironment() { + bool anyPresent = false; + string? firstPresentVariable = null; + + foreach (string variableName in OptOutVariableNames) { + string? value; + try { + value = Environment.GetEnvironmentVariable(variableName); + } + catch { + continue; + } + + if (value is null) + continue; + + anyPresent = true; + firstPresentVariable ??= variableName; + + if (IsTruthyValue(value)) { + return new TelemetryDecision( + false, + "process environment", + "process-level", + variableName, + variableName, + $"Telemetry is disabled by process environment variable {variableName}."); + } + } + + if (!anyPresent || firstPresentVariable is null) + return null; + + return new TelemetryDecision( + true, + "process environment", + "process-level", + firstPresentVariable, + firstPresentVariable, + $"Telemetry is enabled by process environment variable {firstPresentVariable}."); + } + + private static TelemetryDecision? EvaluateRepository(string repoRoot) { + return EvaluateRepositoryConfig(Path.Combine(repoRoot, RepositoryConfigFileName)) + ?? EvaluateDotEnvFile(Path.Combine(repoRoot, DotEnvLocalFileName), DotEnvLocalFileName) + ?? EvaluateDotEnvFile(Path.Combine(repoRoot, DotEnvFileName), DotEnvFileName); + } + + private static TelemetryDecision? EvaluateRepositoryConfig(string path) { + if (!TryReadTextFileCapped(path, MaxRepositoryConfigBytes, out string text)) + return null; + + try { + using JsonDocument doc = JsonDocument.Parse(text); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return null; + + if (!TryGetPropertyCaseInsensitive(doc.RootElement, "disabled", out JsonElement disabledElement)) + return null; + + bool isDisabled = IsTruthyJsonValue(disabledElement); + return new TelemetryDecision( + !isDisabled, + RepositoryConfigFileName, + "repo-local", + path, + null, + isDisabled + ? $"Telemetry is disabled by {RepositoryConfigFileName}." + : $"Telemetry is enabled by {RepositoryConfigFileName}."); + } + catch { + return null; + } + } + + private static TelemetryDecision? EvaluateDotEnvFile(string path, string sourceName) { + if (!TryReadTextFileCapped(path, MaxRepositoryEnvFileBytes, out string text)) + return null; + + bool anyRecognizedAssignment = false; + string? firstRecognizedVariable = null; + + using StringReader reader = new(text); + string? line; + while ((line = reader.ReadLine()) is not null) { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + continue; + + if (trimmed.StartsWith("export ", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed["export ".Length..].TrimStart(); + + int equalsIndex = trimmed.IndexOf('='); + if (equalsIndex <= 0) + continue; + + string key = trimmed[..equalsIndex].Trim(); + if (!IsRecognizedOptOutKey(key)) + continue; + + anyRecognizedAssignment = true; + firstRecognizedVariable ??= key; + + string value = NormalizeDotEnvValue(trimmed[(equalsIndex + 1)..]); + if (IsTruthyValue(value)) { + return new TelemetryDecision( + false, + sourceName, + "repo-local", + path, + key, + $"Telemetry is disabled by {sourceName} ({key})."); + } + } + + if (!anyRecognizedAssignment || firstRecognizedVariable is null) + return null; + + return new TelemetryDecision( + true, + sourceName, + "repo-local", + path, + firstRecognizedVariable, + $"Telemetry is enabled by {sourceName} ({firstRecognizedVariable})."); + } + + private static async Task WriteDisabledConfigAsync(string path) { + JsonObject config = ReadConfigObject(path); + RemovePropertyCaseInsensitive(config, "disabled"); + config["disabled"] = true; + + string json = JsonSerializer.Serialize(config, JsonOptions); + await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + } + + private static async Task EnableConfigAsync(string path) { + if (!File.Exists(path)) + return TelemetryConfigMutation.None; + + JsonObject? config = TryReadExistingConfigObject(path); + if (config is null) + return TelemetryConfigMutation.None; + + if (!TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName)) + return TelemetryConfigMutation.None; + + if (config.Count == 1) { + File.Delete(path); + return TelemetryConfigMutation.Removed; + } + + config.Remove(propertyName!); + config["disabled"] = false; + + string json = JsonSerializer.Serialize(config, JsonOptions); + await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + return TelemetryConfigMutation.RewrittenEnabled; + } + + private static JsonObject ReadConfigObject(string path) { + JsonObject? existing = TryReadExistingConfigObject(path); + return existing ?? []; + } + + private static JsonObject? TryReadExistingConfigObject(string path) { + if (!TryReadTextFileCapped(path, MaxRepositoryConfigBytes, out string text)) + return null; + + try { + return JsonNode.Parse(text) as JsonObject; + } + catch { + return null; + } + } + + private static bool TryReadTextFileCapped(string path, int maxBytes, out string text) { + text = string.Empty; + + try { + FileInfo fileInfo = new(path); + if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > maxBytes) + return false; + + text = File.ReadAllText(path); + return text.Length > 0; + } + catch { + return false; + } + } + + private static bool TryGetPropertyCaseInsensitive(JsonElement element, string name, out JsonElement value) { + JsonProperty property = element.EnumerateObject() + .FirstOrDefault(property => property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (!property.Equals(default(JsonProperty))) { + value = property.Value; + return true; + } + + value = default; + return false; + } + + private static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) { + KeyValuePair property = obj + .FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (property.Key is not null) { + propertyName = property.Key; + return true; + } + + propertyName = null; + return false; + } + + private static void RemovePropertyCaseInsensitive(JsonObject obj, string name) { + if (TryGetPropertyNameCaseInsensitive(obj, name, out string? propertyName)) + obj.Remove(propertyName!); + } + + private static string NormalizeDotEnvValue(string value) { + value = value.Trim(); + + if (value.Length >= 2) { + char first = value[0]; + char last = value[^1]; + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + value = value[1..^1].Trim(); + } + } + + return value; + } + + private static bool IsTruthyJsonValue(JsonElement element) { + return element.ValueKind switch { + JsonValueKind.True => true, + JsonValueKind.String => IsTruthyValue(element.GetString()), + JsonValueKind.Number => IsTruthyValue(element.GetRawText()), + _ => false + }; + } + + private static bool IsRecognizedOptOutKey(string key) { + return OptOutVariableNames.Contains(key, StringComparer.Ordinal); + } + + private static bool IsTruthyValue(string? value) { + if (string.IsNullOrWhiteSpace(value)) + return false; + + string normalized = value.Trim(); + return normalized == "1" + || normalized.Equals("true", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("y", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("on", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct TelemetryDecision( + bool IsEnabled, + string Source, + string Scope, + string? PathOrVariable, + string? VariableName, + string Message); + + private enum TelemetryConfigMutation { + None, + Removed, + RewrittenEnabled + } + } + + internal sealed class TelemetryStatusResult { + public bool IsEnabled { get; init; } + public string WinningSource { get; init; } = string.Empty; + public string? WinningPathOrVariable { get; init; } + public string? WinningVariableName { get; init; } + public string Scope { get; init; } = string.Empty; + public string RepoRoot { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public string? Note { get; init; } + } +} From 2b212a9aecafac609d647487842d5c943ce58ed6 Mon Sep 17 00:00:00 2001 From: KeelMatrix Date: Mon, 30 Mar 2026 20:01:52 +0700 Subject: [PATCH 2/4] Reuse shared telemetry helper for CLI telemetry commands --- .../TelemetryCommandTests.cs | 45 ++- .../Telemetry/RepoRootResolver.cs | 73 ---- .../Telemetry/TelemetryCommandHandler.cs | 349 ++++-------------- 3 files changed, 117 insertions(+), 350 deletions(-) delete mode 100644 tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs index b16adf7..7c21b64 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs @@ -129,7 +129,6 @@ public void Telemetry_Status_Shows_Process_Override_When_Process_Environment_Mas _ = stdout.Should().Contain("Telemetry: enabled"); _ = stdout.Should().Contain("Source: process environment"); _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); - _ = stdout.Should().Contain("Note: repo-local config is ignored while this process-level override is present"); } [Fact] @@ -146,13 +145,15 @@ public void Telemetry_Status_Json_Uses_Status_Model() { using JsonDocument doc = JsonDocument.Parse(stdout); _ = doc.RootElement.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); - _ = doc.RootElement.GetProperty("winningSource").GetString().Should().Be(".env.local"); - _ = doc.RootElement.GetProperty("winningPathOrVariable").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); + _ = doc.RootElement.GetProperty("winningSourceKind").GetString().Should().Be("dotEnvLocal"); + _ = doc.RootElement.GetProperty("winningPath").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); _ = doc.RootElement.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); + _ = doc.RootElement.GetProperty("scope").GetString().Should().Be("repoLocal"); + _ = doc.RootElement.GetProperty("repoRoot").GetString().Should().Be(repo.Root); } [Fact] - public void Telemetry_Disable_Writes_Repository_Config() { + public void Telemetry_Disable_Writes_Qwatch_Managed_Repository_Config() { using RepoScope repo = RepoScope.Create(); (int code, string stdout, string stderr) = CliRunner.Run( @@ -166,12 +167,18 @@ public void Telemetry_Disable_Writes_Repository_Config() { using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeTrue(); + _ = doc.RootElement.GetProperty("qwatchManaged").GetBoolean().Should().BeTrue(); } [Fact] public void Telemetry_Enable_Removes_Qwatch_Managed_Config_File() { using RepoScope repo = RepoScope.Create(); - string configPath = repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"qwatchManaged\": true" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); (int code, string stdout, string stderr) = CliRunner.Run( ["telemetry", "enable"], @@ -180,7 +187,7 @@ public void Telemetry_Enable_Removes_Qwatch_Managed_Config_File() { _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); _ = File.Exists(configPath).Should().BeFalse(); - _ = stdout.Should().Contain("Repo-local telemetry opt-out removed"); + _ = stdout.Should().Contain("Repo-local qwatch-managed telemetry opt-out removed"); _ = stdout.Should().Contain("Telemetry: enabled"); } @@ -190,6 +197,7 @@ public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Co string configPath = repo.WriteFile( "{" + Environment.NewLine + " \"disabled\": true," + Environment.NewLine + + " \"qwatchManaged\": true," + Environment.NewLine + " \"channel\": \"dev\"" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); @@ -203,8 +211,31 @@ public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Co _ = File.Exists(configPath).Should().BeTrue(); using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); - _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeFalse(); _ = doc.RootElement.GetProperty("channel").GetString().Should().Be("dev"); + _ = doc.RootElement.GetProperty("qwatchManaged").GetBoolean().Should().BeTrue(); + _ = doc.RootElement.TryGetProperty("disabled", out _).Should().BeFalse(); + _ = stdout.Should().Contain("Repo-local qwatch-managed telemetry config updated"); + } + + [Fact] + public void Telemetry_Enable_Does_Not_Modify_Non_Qwatch_Managed_Config() { + using RepoScope repo = RepoScope.Create(); + string originalConfig = + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine; + string configPath = repo.WriteFile(originalConfig, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.ReadAllText(configPath).Should().Be(originalConfig); + _ = stdout.Should().Contain("not qwatch-managed and was left unchanged"); + _ = stdout.Should().Contain("Telemetry: disabled"); } [Fact] diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs deleted file mode 100644 index 61fa7f8..0000000 --- a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoRootResolver.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) KeelMatrix - -namespace KeelMatrix.QueryWatch.Cli.Telemetry { - // Modified copy of KeelMatrix.Telemetry ProjectIdentity/GitDiscovery repo-root walking, - // narrowed to the current working directory for qwatch repo-scoped telemetry commands. - internal static class RepoRootResolver { - private const int MaxUpwardSteps = 32; - - internal static bool TryFindRepositoryRootFromCurrentDirectory(out string repositoryRoot) { - repositoryRoot = string.Empty; - - string? current = SafeGetFullPath(Environment.CurrentDirectory); - if (string.IsNullOrEmpty(current)) - return false; - - string? fallbackRepositoryRoot = null; - - for (int i = 0; i <= MaxUpwardSteps && !string.IsNullOrEmpty(current); i++) { - if (HasGitRepositoryRoot(current)) { - repositoryRoot = current; - return true; - } - - if (fallbackRepositoryRoot is null && LooksLikeNonGitRepositoryRoot(current)) - fallbackRepositoryRoot = current; - - current = SafeGetParentDirectory(current); - } - - if (!string.IsNullOrEmpty(fallbackRepositoryRoot)) { - repositoryRoot = fallbackRepositoryRoot; - return true; - } - - return false; - } - - private static bool HasGitRepositoryRoot(string dir) { - try { - string dotGitPath = Path.Combine(dir, ".git"); - return Directory.Exists(dotGitPath) || File.Exists(dotGitPath); - } - catch { - return false; - } - } - - private static bool LooksLikeNonGitRepositoryRoot(string dir) { - try { - return File.Exists(Path.Combine(dir, "global.json")) - || File.Exists(Path.Combine(dir, "Directory.Build.props")) - || File.Exists(Path.Combine(dir, "Directory.Build.targets")) - || File.Exists(Path.Combine(dir, "NuGet.config")); - } - catch { - return false; - } - } - - private static string SafeGetFullPath(string path) { - try { return Path.GetFullPath(path); } catch { return string.Empty; } - } - - private static string? SafeGetParentDirectory(string path) { - try { - return Directory.GetParent(path)?.FullName; - } - catch { - return null; - } - } - } -} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs index e25ef1c..572f4ee 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs @@ -7,32 +7,24 @@ using System.Text.Json.Serialization; using KeelMatrix.QueryWatch.Cli.Core; using KeelMatrix.QueryWatch.Cli.Options; +using KeelMatrix.Telemetry; namespace KeelMatrix.QueryWatch.Cli.Telemetry { internal static class TelemetryCommandHandler { - // Modified copy of KeelMatrix.Telemetry TelemetryDisableResolver precedence and file/env parsing - // so the CLI status surface stays aligned with the package's source-of-truth behavior. private const string RepositoryConfigFileName = "keelmatrix.telemetry.json"; - private const string DotEnvFileName = ".env"; - private const string DotEnvLocalFileName = ".env.local"; private const int MaxRepositoryConfigBytes = 16 * 1024; - private const int MaxRepositoryEnvFileBytes = 16 * 1024; - - private static readonly string[] OptOutVariableNames = [ - "KEELMATRIX_NO_TELEMETRY", - "DOTNET_CLI_TELEMETRY_OPTOUT", - "DO_NOT_TRACK" - ]; + private const string QwatchManagedPropertyName = "qwatchManaged"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; public static async Task ExecuteAsync(TelemetryCommandLineOptions options) { string currentDirectory = Environment.CurrentDirectory; - if (!RepoRootResolver.TryFindRepositoryRootFromCurrentDirectory(out string repoRoot)) { + if (!RepositoryTelemetry.TryResolveRepositoryRoot(currentDirectory, out string repoRoot)) { await Console.Error.WriteLineAsync( $"No repository root could be resolved from the current working directory '{currentDirectory}'.") .ConfigureAwait(false); @@ -48,7 +40,7 @@ await Console.Error.WriteLineAsync( } private static async Task ExecuteStatusAsync(string repoRoot, bool json) { - TelemetryStatusResult status = GetStatus(repoRoot); + RepositoryTelemetryStatus status = GetStatus(repoRoot); if (json) { await Console.Out.WriteLineAsync(JsonSerializer.Serialize(status, JsonOptions)).ConfigureAwait(false); @@ -64,8 +56,8 @@ private static async Task ExecuteDisableAsync(string repoRoot) { string path = Path.Combine(repoRoot, RepositoryConfigFileName); await WriteDisabledConfigAsync(path).ConfigureAwait(false); - TelemetryStatusResult status = GetStatus(repoRoot); - await Console.Out.WriteLineAsync($"Repo-local telemetry opt-out written: {path}").ConfigureAwait(false); + RepositoryTelemetryStatus status = GetStatus(repoRoot); + await Console.Out.WriteLineAsync($"Repo-local qwatch-managed telemetry opt-out written: {path}").ConfigureAwait(false); await WriteEffectiveStatusAsync(status).ConfigureAwait(false); return ExitCodes.Ok; } @@ -73,11 +65,12 @@ private static async Task ExecuteDisableAsync(string repoRoot) { private static async Task ExecuteEnableAsync(string repoRoot) { string path = Path.Combine(repoRoot, RepositoryConfigFileName); TelemetryConfigMutation mutation = await EnableConfigAsync(path).ConfigureAwait(false); - TelemetryStatusResult status = GetStatus(repoRoot); + RepositoryTelemetryStatus status = GetStatus(repoRoot); string message = mutation switch { - TelemetryConfigMutation.Removed => $"Repo-local telemetry opt-out removed: {path}", - TelemetryConfigMutation.RewrittenEnabled => $"Repo-local telemetry config set to enabled: {path}", + TelemetryConfigMutation.Removed => $"Repo-local qwatch-managed telemetry opt-out removed: {path}", + TelemetryConfigMutation.RewrittenEnabled => $"Repo-local qwatch-managed telemetry config updated: {path}", + TelemetryConfigMutation.NotQwatchManaged => $"Existing repo-local telemetry config is not qwatch-managed and was left unchanged: {path}", _ => "No qwatch-managed repo-local telemetry opt-out was found." }; @@ -86,220 +79,43 @@ private static async Task ExecuteEnableAsync(string repoRoot) { return ExitCodes.Ok; } - private static async Task WriteEffectiveStatusAsync(TelemetryStatusResult status) { + private static async Task WriteEffectiveStatusAsync(RepositoryTelemetryStatus status) { await Console.Out.WriteLineAsync($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}").ConfigureAwait(false); + await Console.Out.WriteLineAsync($"Source: {FormatSource(status.WinningSourceKind)}").ConfigureAwait(false); - if (string.Equals(status.WinningSource, "none", StringComparison.Ordinal)) - return; + if (!string.IsNullOrEmpty(status.WinningPath)) + await Console.Out.WriteLineAsync($"Path: {status.WinningPath}").ConfigureAwait(false); - if (string.Equals(status.WinningSource, "process environment", StringComparison.Ordinal)) { - await Console.Out - .WriteLineAsync($"Source: process environment variable {status.WinningVariableName}") - .ConfigureAwait(false); - } - else if (!string.IsNullOrEmpty(status.WinningPathOrVariable)) { - await Console.Out - .WriteLineAsync($"Source: {status.WinningSource} ({status.WinningPathOrVariable})") - .ConfigureAwait(false); - } - else { - await Console.Out.WriteLineAsync($"Source: {status.WinningSource}").ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(status.Note)) - await Console.Out.WriteLineAsync($"Note: {status.Note}").ConfigureAwait(false); + if (!string.IsNullOrEmpty(status.WinningVariableName)) + await Console.Out.WriteLineAsync($"Variable: {status.WinningVariableName}").ConfigureAwait(false); } - private static string FormatHumanStatus(TelemetryStatusResult status) { + private static string FormatHumanStatus(RepositoryTelemetryStatus status) { StringBuilder sb = new(); _ = sb.AppendLine($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}"); - _ = sb.AppendLine($"Source: {status.WinningSource}"); + _ = sb.AppendLine($"Source: {FormatSource(status.WinningSourceKind)}"); - if (!string.IsNullOrEmpty(status.WinningPathOrVariable)) { - string label = string.Equals(status.Scope, "process-level", StringComparison.Ordinal) - ? "Variable" - : "Path"; - _ = sb.AppendLine($"{label}: {status.WinningPathOrVariable}"); - } + if (!string.IsNullOrEmpty(status.WinningPath)) + _ = sb.AppendLine($"Path: {status.WinningPath}"); - if (!string.IsNullOrEmpty(status.WinningVariableName) && - !string.Equals(status.Scope, "process-level", StringComparison.Ordinal)) { + if (!string.IsNullOrEmpty(status.WinningVariableName)) _ = sb.AppendLine($"Variable: {status.WinningVariableName}"); - } - _ = sb.AppendLine($"Scope: {status.Scope}"); + _ = sb.AppendLine($"Scope: {FormatScope(status.Scope)}"); _ = sb.AppendLine($"Repo: {status.RepoRoot}"); - - if (!string.IsNullOrWhiteSpace(status.Note)) - _ = sb.AppendLine($"Note: {status.Note}"); - return sb.ToString().TrimEnd(); } - private static TelemetryStatusResult GetStatus(string repoRoot) { - TelemetryDecision? repositoryDecision = EvaluateRepository(repoRoot); - TelemetryDecision? processDecision = EvaluateProcessEnvironment(); - TelemetryDecision? winningDecision = processDecision ?? repositoryDecision; - - if (winningDecision is null) { - return new TelemetryStatusResult { - IsEnabled = true, - WinningSource = "none", - Scope = "repo-local default", - RepoRoot = repoRoot, - Message = "Telemetry is enabled; no process or repo-local override was found." - }; - } - - string? note = processDecision is not null && repositoryDecision is not null - ? "repo-local config is ignored while this process-level override is present" - : null; - TelemetryDecision resolvedDecision = winningDecision.Value; - - return new TelemetryStatusResult { - IsEnabled = resolvedDecision.IsEnabled, - WinningSource = resolvedDecision.Source, - WinningPathOrVariable = resolvedDecision.PathOrVariable, - WinningVariableName = resolvedDecision.VariableName, - Scope = resolvedDecision.Scope, - RepoRoot = repoRoot, - Message = resolvedDecision.Message, - Note = note - }; - } - - private static TelemetryDecision? EvaluateProcessEnvironment() { - bool anyPresent = false; - string? firstPresentVariable = null; - - foreach (string variableName in OptOutVariableNames) { - string? value; - try { - value = Environment.GetEnvironmentVariable(variableName); - } - catch { - continue; - } - - if (value is null) - continue; - - anyPresent = true; - firstPresentVariable ??= variableName; - - if (IsTruthyValue(value)) { - return new TelemetryDecision( - false, - "process environment", - "process-level", - variableName, - variableName, - $"Telemetry is disabled by process environment variable {variableName}."); - } - } - - if (!anyPresent || firstPresentVariable is null) - return null; - - return new TelemetryDecision( - true, - "process environment", - "process-level", - firstPresentVariable, - firstPresentVariable, - $"Telemetry is enabled by process environment variable {firstPresentVariable}."); - } - - private static TelemetryDecision? EvaluateRepository(string repoRoot) { - return EvaluateRepositoryConfig(Path.Combine(repoRoot, RepositoryConfigFileName)) - ?? EvaluateDotEnvFile(Path.Combine(repoRoot, DotEnvLocalFileName), DotEnvLocalFileName) - ?? EvaluateDotEnvFile(Path.Combine(repoRoot, DotEnvFileName), DotEnvFileName); - } - - private static TelemetryDecision? EvaluateRepositoryConfig(string path) { - if (!TryReadTextFileCapped(path, MaxRepositoryConfigBytes, out string text)) - return null; - - try { - using JsonDocument doc = JsonDocument.Parse(text); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return null; - - if (!TryGetPropertyCaseInsensitive(doc.RootElement, "disabled", out JsonElement disabledElement)) - return null; - - bool isDisabled = IsTruthyJsonValue(disabledElement); - return new TelemetryDecision( - !isDisabled, - RepositoryConfigFileName, - "repo-local", - path, - null, - isDisabled - ? $"Telemetry is disabled by {RepositoryConfigFileName}." - : $"Telemetry is enabled by {RepositoryConfigFileName}."); - } - catch { - return null; - } - } - - private static TelemetryDecision? EvaluateDotEnvFile(string path, string sourceName) { - if (!TryReadTextFileCapped(path, MaxRepositoryEnvFileBytes, out string text)) - return null; - - bool anyRecognizedAssignment = false; - string? firstRecognizedVariable = null; - - using StringReader reader = new(text); - string? line; - while ((line = reader.ReadLine()) is not null) { - string trimmed = line.Trim(); - if (trimmed.Length == 0 || trimmed[0] == '#') - continue; - - if (trimmed.StartsWith("export ", StringComparison.OrdinalIgnoreCase)) - trimmed = trimmed["export ".Length..].TrimStart(); - - int equalsIndex = trimmed.IndexOf('='); - if (equalsIndex <= 0) - continue; - - string key = trimmed[..equalsIndex].Trim(); - if (!IsRecognizedOptOutKey(key)) - continue; - - anyRecognizedAssignment = true; - firstRecognizedVariable ??= key; - - string value = NormalizeDotEnvValue(trimmed[(equalsIndex + 1)..]); - if (IsTruthyValue(value)) { - return new TelemetryDecision( - false, - sourceName, - "repo-local", - path, - key, - $"Telemetry is disabled by {sourceName} ({key})."); - } - } - - if (!anyRecognizedAssignment || firstRecognizedVariable is null) - return null; - - return new TelemetryDecision( - true, - sourceName, - "repo-local", - path, - firstRecognizedVariable, - $"Telemetry is enabled by {sourceName} ({firstRecognizedVariable})."); + private static RepositoryTelemetryStatus GetStatus(string repoRoot) { + return RepositoryTelemetry.GetEffectiveStatus(repoRoot); } private static async Task WriteDisabledConfigAsync(string path) { JsonObject config = ReadConfigObject(path); RemovePropertyCaseInsensitive(config, "disabled"); + RemovePropertyCaseInsensitive(config, QwatchManagedPropertyName); config["disabled"] = true; + config[QwatchManagedPropertyName] = true; string json = JsonSerializer.Serialize(config, JsonOptions); await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); @@ -310,19 +126,20 @@ private static async Task EnableConfigAsync(string path return TelemetryConfigMutation.None; JsonObject? config = TryReadExistingConfigObject(path); - if (config is null) - return TelemetryConfigMutation.None; + if (config is null || !IsQwatchManaged(config)) + return TelemetryConfigMutation.NotQwatchManaged; - if (!TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName)) - return TelemetryConfigMutation.None; + bool hadDisabled = TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName); + if (hadDisabled) + config.Remove(propertyName!); - if (config.Count == 1) { + if (HasOnlyQwatchManagedMarker(config)) { File.Delete(path); return TelemetryConfigMutation.Removed; } - config.Remove(propertyName!); - config["disabled"] = false; + if (!hadDisabled) + return TelemetryConfigMutation.None; string json = JsonSerializer.Serialize(config, JsonOptions); await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); @@ -362,18 +179,6 @@ private static bool TryReadTextFileCapped(string path, int maxBytes, out string } } - private static bool TryGetPropertyCaseInsensitive(JsonElement element, string name, out JsonElement value) { - JsonProperty property = element.EnumerateObject() - .FirstOrDefault(property => property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - if (!property.Equals(default(JsonProperty))) { - value = property.Value; - return true; - } - - value = default; - return false; - } - private static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) { KeyValuePair property = obj .FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase)); @@ -391,31 +196,33 @@ private static void RemovePropertyCaseInsensitive(JsonObject obj, string name) { obj.Remove(propertyName!); } - private static string NormalizeDotEnvValue(string value) { - value = value.Trim(); - - if (value.Length >= 2) { - char first = value[0]; - char last = value[^1]; - if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { - value = value[1..^1].Trim(); - } - } + private static bool IsQwatchManaged(JsonObject config) { + if (!TryGetPropertyNameCaseInsensitive(config, QwatchManagedPropertyName, out string? propertyName)) + return false; - return value; + return IsTruthyJsonNode(config[propertyName!]); } - private static bool IsTruthyJsonValue(JsonElement element) { - return element.ValueKind switch { - JsonValueKind.True => true, - JsonValueKind.String => IsTruthyValue(element.GetString()), - JsonValueKind.Number => IsTruthyValue(element.GetRawText()), - _ => false - }; + private static bool HasOnlyQwatchManagedMarker(JsonObject config) { + return config.Count == 1 && IsQwatchManaged(config); } - private static bool IsRecognizedOptOutKey(string key) { - return OptOutVariableNames.Contains(key, StringComparer.Ordinal); + private static bool IsTruthyJsonNode(JsonNode? node) { + if (node is null) + return false; + + try { + using JsonDocument doc = JsonDocument.Parse(node.ToJsonString()); + return doc.RootElement.ValueKind switch { + JsonValueKind.True => true, + JsonValueKind.String => IsTruthyValue(doc.RootElement.GetString()), + JsonValueKind.Number => IsTruthyValue(doc.RootElement.GetRawText()), + _ => false + }; + } + catch { + return false; + } } private static bool IsTruthyValue(string? value) { @@ -430,29 +237,31 @@ private static bool IsTruthyValue(string? value) { || normalized.Equals("on", StringComparison.OrdinalIgnoreCase); } - private readonly record struct TelemetryDecision( - bool IsEnabled, - string Source, - string Scope, - string? PathOrVariable, - string? VariableName, - string Message); + private static string FormatSource(RepositoryTelemetrySourceKind sourceKind) { + return sourceKind switch { + RepositoryTelemetrySourceKind.None => "none", + RepositoryTelemetrySourceKind.ProcessEnvironment => "process environment", + RepositoryTelemetrySourceKind.RepositoryConfig => RepositoryConfigFileName, + RepositoryTelemetrySourceKind.DotEnvLocal => ".env.local", + RepositoryTelemetrySourceKind.DotEnv => ".env", + _ => "none" + }; + } + + private static string FormatScope(RepositoryTelemetryScope scope) { + return scope switch { + RepositoryTelemetryScope.RepoLocalDefault => "repo-local default", + RepositoryTelemetryScope.ProcessEnvironment => "process-level", + RepositoryTelemetryScope.RepoLocal => "repo-local", + _ => "repo-local default" + }; + } private enum TelemetryConfigMutation { None, Removed, - RewrittenEnabled + RewrittenEnabled, + NotQwatchManaged } } - - internal sealed class TelemetryStatusResult { - public bool IsEnabled { get; init; } - public string WinningSource { get; init; } = string.Empty; - public string? WinningPathOrVariable { get; init; } - public string? WinningVariableName { get; init; } - public string Scope { get; init; } = string.Empty; - public string RepoRoot { get; init; } = string.Empty; - public string Message { get; init; } = string.Empty; - public string? Note { get; init; } - } } From 7781fd9b940c2e3065274456f8de90795199f646 Mon Sep 17 00:00:00 2001 From: KeelMatrix Date: Mon, 30 Mar 2026 22:20:47 +0700 Subject: [PATCH 3/4] Tighten CLI telemetry ownership handling --- PRIVACY.md | 5 + README.md | 10 +- ...ueryWatchTelemetry.cs => TelemetryHost.cs} | 2 +- .../QueryWatchSession.cs | 4 +- .../TelemetryCommandTests.cs | 266 ++++++++++++++++-- .../Options/CliSpec.cs | 10 +- tools/KeelMatrix.QueryWatch.Cli/Program.cs | 8 +- .../QueryWatchCliTelemetry.cs | 28 -- tools/KeelMatrix.QueryWatch.Cli/README.md | 8 +- .../RepoLocalTelemetryConfigInspector.cs | 146 ++++++++++ .../Telemetry/TelemetryCommandHandler.cs | 258 ++++++++--------- .../TelemetryHost.cs | 30 ++ 12 files changed, 552 insertions(+), 223 deletions(-) rename src/KeelMatrix.QueryWatch/Internal/{QueryWatchTelemetry.cs => TelemetryHost.cs} (87%) delete mode 100644 tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs create mode 100644 tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs diff --git a/PRIVACY.md b/PRIVACY.md index 64ef7cd..4ad7afb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -21,5 +21,10 @@ QueryWatch packages use the shared telemetry package, but not every package uses - The main `KeelMatrix.QueryWatch` library uses activation and heartbeat through its session lifecycle. - The `qwatch` CLI sends an activation event on normal execution, but does not send heartbeat events. +- `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` do not emit telemetry. +- `qwatch telemetry disable` writes a repo-local opt-out only when it can safely create or update a qwatch-managed config. +- `qwatch telemetry enable` only removes or neutralizes qwatch-managed repo-local opt-out state. +- QueryWatch-owned repo-local configs use `managedBy: "qwatch"` as the ownership marker. +- Higher-precedence process environment variables still override repo-local config. QueryWatch does not add product-specific telemetry fields on top of the shared telemetry package behavior documented above. If that changes in a way that affects privacy, this file will be updated. diff --git a/README.md b/README.md index dd1a2e5..f8d7ffa 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,8 @@ Usage: qwatch telemetry [options] Commands: -telemetry status [--json] Show effective telemetry state for the current repo. -telemetry disable Write repo-local keelmatrix.telemetry.json with disabled=true. +telemetry status [--json] Show effective telemetry state and repo-local config status for the current repo. +telemetry disable Write a qwatch-managed repo-local telemetry opt-out. telemetry enable Remove or neutralize qwatch-managed repo-local telemetry opt-out. Options: @@ -230,7 +230,7 @@ Options: --baseline Baseline summary JSON to compare against. --baseline-allow-percent P Allow +P% regression vs baseline before failing. --write-baseline Write current aggregated summary to --baseline. ---budget "=" Per-pattern query count budget (repeatable). (repeatable) +--budget "=" Per-pattern query count budget. (repeatable) Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex. --require-full-events Fail if input summaries are top-N sampled. --help Show this help. @@ -242,7 +242,7 @@ Multi-file support: - repeat `--input` to aggregate summaries from multiple test projects - compare current results against a baseline summary - write GitHub Actions step summaries automatically when running in CI -- inspect or manage repo-local telemetry with `qwatch telemetry status|disable|enable` +- inspect or manage repo-local telemetry opt-out state with `qwatch telemetry status|disable|enable` ## Troubleshooting @@ -260,6 +260,8 @@ See: - [PRIVACY.md](PRIVACY.md) for the QueryWatch-specific summary - [KeelMatrix.Telemetry README](https://github.com/KeelMatrix/Telemetry#readme) for the maintained telemetry behavior and opt-out details +For the CLI, `qwatch telemetry disable` writes a repo-local opt-out file and `qwatch telemetry enable` removes or neutralizes only qwatch-managed repo-local opt-out state. QueryWatch-owned files use `managedBy: "qwatch"` as the ownership marker. Higher-precedence process environment variables still win, and existing non-qwatch-managed repo-local config is left untouched. + ## License MIT diff --git a/src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs b/src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs similarity index 87% rename from src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs rename to src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs index 157439c..b45ea05 100644 --- a/src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs +++ b/src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs @@ -3,7 +3,7 @@ using KeelMatrix.Telemetry; namespace KeelMatrix.QueryWatch { - internal static class QueryWatchTelemetry { + internal static class TelemetryHost { private static readonly Client Client = new("QueryWatch", typeof(QueryWatchSession)); internal static void TrackActivation() => Client.TrackActivation(); diff --git a/src/KeelMatrix.QueryWatch/QueryWatchSession.cs b/src/KeelMatrix.QueryWatch/QueryWatchSession.cs index d6a1a6b..2e5a7f0 100644 --- a/src/KeelMatrix.QueryWatch/QueryWatchSession.cs +++ b/src/KeelMatrix.QueryWatch/QueryWatchSession.cs @@ -31,7 +31,7 @@ public QueryWatchSession(QueryWatchOptions? options = null) { Options = options ?? new QueryWatchOptions(); StartedAt = DateTimeOffset.UtcNow; - QueryWatchTelemetry.TrackActivation(); + TelemetryHost.TrackActivation(); } /// Options for this session. @@ -131,7 +131,7 @@ private QueryWatchReport StopInternal() { if (Interlocked.CompareExchange(ref _stopped, 1, 0) == 0) { StoppedAt = now; - QueryWatchTelemetry.TrackHeartbeat(); + TelemetryHost.TrackHeartbeat(); } List snapshot; diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs index 7c21b64..3e36810 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs @@ -2,6 +2,8 @@ using System.Text.Json; using FluentAssertions; +using KeelMatrix.QueryWatch.Cli; +using KeelMatrix.QueryWatch.Cli.Telemetry; using Xunit; namespace KeelMatrix.QueryWatch.Cli.IntegrationTests { @@ -48,6 +50,7 @@ public void Telemetry_Status_Shows_Enabled_When_No_Overrides_Exist() { _ = stdout.Should().Contain("Telemetry: enabled"); _ = stdout.Should().Contain("Source: none"); _ = stdout.Should().Contain($"Repo: {repo.Root}"); + _ = stdout.Should().Contain("Repo-local config: missing"); } [Fact] @@ -81,6 +84,7 @@ public void Telemetry_Status_Shows_Disabled_When_Repository_Config_Disables() { _ = stdout.Should().Contain("Source: keelmatrix.telemetry.json"); _ = stdout.Should().Contain(Path.Combine(repo.Root, "keelmatrix.telemetry.json")); _ = stdout.Should().Contain("Scope: repo-local"); + _ = stdout.Should().Contain("Repo-local config: not qwatch-managed"); } [Fact] @@ -129,6 +133,26 @@ public void Telemetry_Status_Shows_Process_Override_When_Process_Environment_Mas _ = stdout.Should().Contain("Telemetry: enabled"); _ = stdout.Should().Contain("Source: process environment"); _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + _ = stdout.Should().Contain("Repo-local config: not qwatch-managed"); + } + + [Fact] + public void Telemetry_Status_Shows_QueryWatchManaged_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"managedBy\": \"qwatch\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Repo-local config: qwatch-managed"); } [Fact] @@ -144,16 +168,51 @@ public void Telemetry_Status_Json_Uses_Status_Model() { _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); using JsonDocument doc = JsonDocument.Parse(stdout); - _ = doc.RootElement.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); - _ = doc.RootElement.GetProperty("winningSourceKind").GetString().Should().Be("dotEnvLocal"); - _ = doc.RootElement.GetProperty("winningPath").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); - _ = doc.RootElement.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); - _ = doc.RootElement.GetProperty("scope").GetString().Should().Be("repoLocal"); - _ = doc.RootElement.GetProperty("repoRoot").GetString().Should().Be(repo.Root); + JsonElement effectiveStatus = doc.RootElement.GetProperty("effectiveStatus"); + _ = effectiveStatus.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); + _ = effectiveStatus.GetProperty("winningSourceKind").GetString().Should().Be("dotEnvLocal"); + _ = effectiveStatus.GetProperty("winningPath").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); + _ = effectiveStatus.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); + _ = effectiveStatus.GetProperty("scope").GetString().Should().Be("repoLocal"); + _ = effectiveStatus.GetProperty("repoRoot").GetString().Should().Be(repo.Root); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("missing"); + _ = doc.RootElement.GetProperty("repoLocalConfigPath").GetString().Should().Be(Path.Combine(repo.Root, "keelmatrix.telemetry.json")); + } + + [Fact] + public void Telemetry_Status_Json_Reports_Invalid_RepoLocal_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("invalidJson"); + } + + [Fact] + public void Telemetry_Status_Json_Reports_Oversized_RepoLocal_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile(new string('x', RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes + 1), "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("tooLarge"); } [Fact] - public void Telemetry_Disable_Writes_Qwatch_Managed_Repository_Config() { + public void Telemetry_Disable_Creates_Qwatch_Managed_Repository_Config_When_None_Exists() { using RepoScope repo = RepoScope.Create(); (int code, string stdout, string stderr) = CliRunner.Run( @@ -167,7 +226,62 @@ public void Telemetry_Disable_Writes_Qwatch_Managed_Repository_Config() { using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeTrue(); - _ = doc.RootElement.GetProperty("qwatchManaged").GetBoolean().Should().BeTrue(); + _ = doc.RootElement.GetProperty("managedBy").GetString().Should().Be("qwatch"); + } + + [Fact] + public void Telemetry_Disable_Refuses_To_Overwrite_Non_Qwatch_Managed_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": false," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + string originalConfig = File.ReadAllText(configPath); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("Refusing to overwrite existing repo-local telemetry config because it is not qwatch-managed"); + _ = File.ReadAllText(configPath).Should().Be(originalConfig); + } + + [Fact] + public void Telemetry_Disable_Refuses_Invalid_Json_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("contains invalid JSON"); + _ = File.ReadAllText(configPath).Should().Be("{ not-json"); + } + + [Fact] + public void Telemetry_Disable_Refuses_Oversized_Config() { + using RepoScope repo = RepoScope.Create(); + string oversized = new string('x', RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes + 1); + string configPath = repo.WriteFile(oversized, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("exceeds the"); + _ = File.ReadAllText(configPath).Should().Be(oversized); } [Fact] @@ -176,7 +290,7 @@ public void Telemetry_Enable_Removes_Qwatch_Managed_Config_File() { string configPath = repo.WriteFile( "{" + Environment.NewLine + " \"disabled\": true," + Environment.NewLine + - " \"qwatchManaged\": true" + Environment.NewLine + + " \"managedBy\": \"qwatch\"" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); @@ -197,7 +311,7 @@ public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Co string configPath = repo.WriteFile( "{" + Environment.NewLine + " \"disabled\": true," + Environment.NewLine + - " \"qwatchManaged\": true," + Environment.NewLine + + " \"managedBy\": \"qwatch\"," + Environment.NewLine + " \"channel\": \"dev\"" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); @@ -212,7 +326,7 @@ public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Co using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); _ = doc.RootElement.GetProperty("channel").GetString().Should().Be("dev"); - _ = doc.RootElement.GetProperty("qwatchManaged").GetBoolean().Should().BeTrue(); + _ = doc.RootElement.GetProperty("managedBy").GetString().Should().Be("qwatch"); _ = doc.RootElement.TryGetProperty("disabled", out _).Should().BeFalse(); _ = stdout.Should().Contain("Repo-local qwatch-managed telemetry config updated"); } @@ -232,32 +346,57 @@ public void Telemetry_Enable_Does_Not_Modify_Non_Qwatch_Managed_Config() { env: RepoScope.TelemetryEnvironment, workingDirectory: repo.WorkingDirectory); - _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); _ = File.ReadAllText(configPath).Should().Be(originalConfig); - _ = stdout.Should().Contain("not qwatch-managed and was left unchanged"); - _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stderr.Should().Contain("not qwatch-managed and was left unchanged"); } [Fact] - public void Telemetry_Commands_Do_Not_Call_TrackActivation() { + public async Task Telemetry_Commands_Do_Not_Call_TrackActivation() { using RepoScope repo = RepoScope.Create(); - string sentinelPath = Path.Combine(repo.Root, "activation-sentinel.txt"); + using EnvironmentVariableSnapshot envSnapshot = new("KEELMATRIX_NO_TELEMETRY", "DOTNET_CLI_TELEMETRY_OPTOUT", "DO_NOT_TRACK"); + using CurrentDirectoryScope __ = new(repo.WorkingDirectory); + using ConsoleCaptureScope console = new(); + RecordingCliTelemetry telemetry = new(); + ITelemetryHost previousTelemetry = TelemetryHost.Current; + TelemetryHost.Current = telemetry; - (int telemetryCode, string telemetryOut, string telemetryErr) = CliRunner.Run( - ["telemetry", "status"], - env: [.. RepoScope.TelemetryEnvironment, ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", sentinelPath)], - workingDirectory: repo.WorkingDirectory); + try { + Environment.SetEnvironmentVariable("KEELMATRIX_NO_TELEMETRY", null); + Environment.SetEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", null); + Environment.SetEnvironmentVariable("DO_NOT_TRACK", null); - _ = telemetryCode.Should().Be(0, telemetryOut + Environment.NewLine + telemetryErr); - _ = File.Exists(sentinelPath).Should().BeFalse(); + int telemetryCode = await Program.RunAsync(["telemetry", "status"]); + _ = telemetryCode.Should().Be(0, console.StdOut + Environment.NewLine + console.StdErr); + _ = telemetry.ActivationCalls.Should().Be(0); - string inputPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "current_ok.json"); - (int normalCode, string normalOut, string normalErr) = CliRunner.Run( - ["--input", inputPath], - env: [.. RepoScope.TelemetryEnvironment, ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", sentinelPath)]); + console.Clear(); + + string inputPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "current_ok.json"); + int normalCode = await Program.RunAsync(["--input", inputPath]); + _ = normalCode.Should().Be(0, console.StdOut + Environment.NewLine + console.StdErr); + _ = telemetry.ActivationCalls.Should().Be(1); + } + finally { + TelemetryHost.Current = previousTelemetry; + } + } + + [Fact] + public void Telemetry_Enable_Refuses_Invalid_Json_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); - _ = normalCode.Should().Be(0, normalOut + Environment.NewLine + normalErr); - _ = File.Exists(sentinelPath).Should().BeTrue(); + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("contains invalid JSON"); + _ = File.ReadAllText(configPath).Should().Be("{ not-json"); } [Fact] @@ -296,8 +435,7 @@ private sealed class RepoScope : IDisposable { internal static readonly (string Key, string? Value)[] EmptyTelemetryEnvironment = [ ("KEELMATRIX_NO_TELEMETRY", null), ("DOTNET_CLI_TELEMETRY_OPTOUT", null), - ("DO_NOT_TRACK", null), - ("QWATCH_CLI_TRACK_ACTIVATION_SENTINEL", null) + ("DO_NOT_TRACK", null) ]; private RepoScope(string root) { @@ -336,5 +474,73 @@ public void Dispose() { } } } + + private sealed class ConsoleCaptureScope : IDisposable { + private readonly TextWriter originalOut; + private readonly TextWriter originalErr; + private readonly StringWriter stdoutWriter = new(); + private readonly StringWriter stderrWriter = new(); + + public ConsoleCaptureScope() { + originalOut = Console.Out; + originalErr = Console.Error; + Console.SetOut(stdoutWriter); + Console.SetError(stderrWriter); + } + + public string StdOut => stdoutWriter.ToString(); + public string StdErr => stderrWriter.ToString(); + + public void Clear() { + stdoutWriter.GetStringBuilder().Clear(); + stderrWriter.GetStringBuilder().Clear(); + } + + public void Dispose() { + Console.SetOut(originalOut); + Console.SetError(originalErr); + stdoutWriter.Dispose(); + stderrWriter.Dispose(); + } + } + + private sealed class CurrentDirectoryScope : IDisposable { + private readonly string originalCurrentDirectory; + + public CurrentDirectoryScope(string currentDirectory) { + originalCurrentDirectory = Environment.CurrentDirectory; + Environment.CurrentDirectory = currentDirectory; + } + + public void Dispose() { + Environment.CurrentDirectory = originalCurrentDirectory; + } + } + + private sealed class EnvironmentVariableSnapshot : IDisposable { + private readonly (string Name, string? Value)[] snapshot; + + public EnvironmentVariableSnapshot(params string[] names) { + snapshot = new (string, string?)[names.Length]; + for (int i = 0; i < names.Length; i++) { + string name = names[i]; + snapshot[i] = (name, Environment.GetEnvironmentVariable(name)); + } + } + + public void Dispose() { + foreach (var (name, value) in snapshot) + Environment.SetEnvironmentVariable(name, value); + } + } + + private sealed class RecordingCliTelemetry : ITelemetryHost { + public int ActivationCalls { get; private set; } + + public void TrackActivation() { + ActivationCalls++; + } + } + } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs index 8b80ef1..0842a60 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs @@ -15,15 +15,15 @@ internal static class CliSpec { new CliOption("--baseline", "", "Baseline summary JSON to compare against."), new CliOption("--baseline-allow-percent", "P", "Allow +P% regression vs baseline before failing."), new CliOption("--write-baseline", null, "Write current aggregated summary to --baseline."), - new CliOption("--budget", "\"=\"", "Per-pattern query count budget (repeatable).", + new CliOption("--budget", "\"=\"", "Per-pattern query count budget.", Notes: "Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex.", Repeatable: true), new CliOption("--require-full-events", null, "Fail if input summaries are top-N sampled."), new CliOption("--help", null, "Show this help.") ]; public static readonly CliCommand[] TelemetryCommands = [ - new CliCommand("telemetry status [--json]", "Show effective telemetry state for the current repo."), - new CliCommand("telemetry disable", "Write repo-local keelmatrix.telemetry.json with disabled=true."), + new CliCommand("telemetry status [--json]", "Show effective telemetry state and repo-local config status for the current repo."), + new CliCommand("telemetry disable", "Write a qwatch-managed repo-local telemetry opt-out."), new CliCommand("telemetry enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out.") ]; @@ -66,8 +66,8 @@ public static string BuildTelemetryHelpText() { _ = sb.AppendLine(" qwatch telemetry enable"); _ = sb.AppendLine(); _ = sb.AppendLine("Commands:"); - _ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state for the current repo."); - _ = AppendAlignedLine(sb, leftWidth, "disable", "Write repo-local keelmatrix.telemetry.json with disabled=true."); + _ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state and repo-local config status for the current repo."); + _ = AppendAlignedLine(sb, leftWidth, "disable", "Write a qwatch-managed repo-local telemetry opt-out."); _ = AppendAlignedLine(sb, leftWidth, "enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out."); _ = sb.AppendLine(); _ = sb.AppendLine("Options:"); diff --git a/tools/KeelMatrix.QueryWatch.Cli/Program.cs b/tools/KeelMatrix.QueryWatch.Cli/Program.cs index 48ae92c..65edba8 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Program.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Program.cs @@ -6,7 +6,11 @@ namespace KeelMatrix.QueryWatch.Cli { internal static class Program { - private static async Task Main(string[] args) { + private static Task Main(string[] args) { + return RunAsync(args); + } + + internal static async Task RunAsync(string[] args) { // 0) No arguments → show help if (args.Length == 0) { Console.WriteLine(CommandLineOptions.HelpText); @@ -42,7 +46,7 @@ private static async Task Main(string[] args) { return await TelemetryCommandHandler.ExecuteAsync(parsed.TelemetryOptions).ConfigureAwait(false); // 4) Normal execution - QueryWatchCliTelemetry.TrackActivation(); + TelemetryHost.TrackActivation(); return await Runner.ExecuteAsync(parsed.Options!).ConfigureAwait(false); } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs b/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs deleted file mode 100644 index 4d8db60..0000000 --- a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) KeelMatrix - -using KeelMatrix.Telemetry; - -namespace KeelMatrix.QueryWatch.Cli { - internal static class QueryWatchCliTelemetry { - private const string ActivationSentinelPathEnvVar = "QWATCH_CLI_TRACK_ACTIVATION_SENTINEL"; - private static readonly Client Client = new("qwatchCLI", typeof(Program)); - - internal static void TrackActivation() { - TryWriteActivationSentinel(); - Client.TrackActivation(); - } - - private static void TryWriteActivationSentinel() { - try { - string? path = Environment.GetEnvironmentVariable(ActivationSentinelPathEnvVar); - if (string.IsNullOrWhiteSpace(path)) - return; - - File.AppendAllText(path, "activated" + Environment.NewLine); - } - catch { - // Test-only best effort; never block telemetry or CLI execution. - } - } - } -} diff --git a/tools/KeelMatrix.QueryWatch.Cli/README.md b/tools/KeelMatrix.QueryWatch.Cli/README.md index 3f2a4e8..b6bc797 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/README.md +++ b/tools/KeelMatrix.QueryWatch.Cli/README.md @@ -31,19 +31,19 @@ Show help: qwatch --help ``` -Inspect telemetry state for the current repo: +Inspect effective telemetry state and repo-local config status for the current repo: ```bash qwatch telemetry status ``` -Disable telemetry for the current repo: +Write a qwatch-managed repo-local telemetry opt-out for the current repo: ```bash qwatch telemetry disable ``` -Re-enable telemetry for the current repo: +Remove a qwatch-managed repo-local telemetry opt-out for the current repo: ```bash qwatch telemetry enable @@ -155,7 +155,7 @@ If you only need the file format in another tool, see the contracts package: `qwatch` sends a minimal anonymous telemetry activation event on normal CLI execution. -Telemetry management commands do not emit telemetry. Use `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` to inspect or manage repo-local telemetry behavior without introducing a second config model. +Telemetry management commands do not emit telemetry. Use `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` to inspect or manage the repo-local opt-out file without introducing a second config model. These commands stay repo-scoped to the current working directory, and process environment variables still take precedence over repo-local config. QueryWatch-owned files use `managedBy: "qwatch"` as the ownership marker. If an existing `keelmatrix.telemetry.json` is not qwatch-managed, the CLI fails safely instead of overwriting it. It does not send heartbeat events. Reason: `qwatch` is typically a short-lived CI/local tool, so weekly heartbeat would mostly reflect retained pipeline wiring rather than meaningful interactive product usage. diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs new file mode 100644 index 0000000..815c0f1 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs @@ -0,0 +1,146 @@ +// Copyright (c) KeelMatrix + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace KeelMatrix.QueryWatch.Cli.Telemetry { + internal static class RepoLocalTelemetryConfigInspector { + internal const string RepositoryConfigFileName = "keelmatrix.telemetry.json"; + internal const string ManagedByPropertyName = "managedBy"; + internal const string ManagedByQwatchValue = "qwatch"; + internal const int MaxRepositoryConfigBytes = 16 * 1024; + + internal static RepoLocalTelemetryConfigInspection Inspect(string repoRoot) { + return InspectPath(Path.Combine(repoRoot, RepositoryConfigFileName)); + } + + internal static RepoLocalTelemetryConfigInspection InspectPath(string path) { + FileInfo fileInfo; + try { + fileInfo = new FileInfo(path); + } + catch { + return RepoLocalTelemetryConfigInspection.Unreadable(path); + } + + try { + if (!fileInfo.Exists) + return RepoLocalTelemetryConfigInspection.Missing(path); + + if (fileInfo.Length > MaxRepositoryConfigBytes) + return RepoLocalTelemetryConfigInspection.TooLarge(path); + + string text = File.ReadAllText(path); + if (text.Length == 0) + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + + JsonNode? node = JsonNode.Parse(text); + if (node is not JsonObject config) + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + + return IsManagedByQueryWatch(config) + ? RepoLocalTelemetryConfigInspection.QueryWatchManaged(path, config) + : RepoLocalTelemetryConfigInspection.NotQueryWatchManaged(path, config); + } + catch (JsonException) { + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + } + catch { + return RepoLocalTelemetryConfigInspection.Unreadable(path); + } + } + + internal static JsonObject CreateNewManagedOptOutConfig() { + return new JsonObject { + ["disabled"] = true, + [ManagedByPropertyName] = ManagedByQwatchValue + }; + } + + internal static void ApplyManagedOptOut(JsonObject config) { + RemovePropertyCaseInsensitive(config, "disabled"); + RemovePropertyCaseInsensitive(config, ManagedByPropertyName); + config["disabled"] = true; + config[ManagedByPropertyName] = ManagedByQwatchValue; + } + + internal static void NormalizeManagedMarker(JsonObject config) { + if (!IsManagedByQueryWatch(config)) + return; + + RemovePropertyCaseInsensitive(config, ManagedByPropertyName); + config[ManagedByPropertyName] = ManagedByQwatchValue; + } + + internal static bool IsManagedByQueryWatch(JsonObject config) { + if (!TryGetPropertyNameCaseInsensitive(config, ManagedByPropertyName, out string? propertyName)) + return false; + + return config[propertyName!] is JsonValue value + && value.TryGetValue(out string? managedBy) + && string.Equals(managedBy, ManagedByQwatchValue, StringComparison.Ordinal); + } + + internal static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) { + KeyValuePair property = obj + .FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (property.Key is not null) { + propertyName = property.Key; + return true; + } + + propertyName = null; + return false; + } + + internal static void RemovePropertyCaseInsensitive(JsonObject obj, string name) { + if (TryGetPropertyNameCaseInsensitive(obj, name, out string? propertyName)) + obj.Remove(propertyName!); + } + } + + internal enum RepoLocalTelemetryConfigStateKind { + Missing = 0, + QueryWatchManaged = 1, + NotQueryWatchManaged = 2, + Unreadable = 3, + InvalidJson = 4, + TooLarge = 5 + } + + internal sealed class RepoLocalTelemetryConfigInspection { + private RepoLocalTelemetryConfigInspection(string path, RepoLocalTelemetryConfigStateKind stateKind, JsonObject? config) { + Path = path; + StateKind = stateKind; + Config = config; + } + + internal string Path { get; } + internal RepoLocalTelemetryConfigStateKind StateKind { get; } + internal JsonObject? Config { get; } + + internal static RepoLocalTelemetryConfigInspection Missing(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Missing, config: null); + } + + internal static RepoLocalTelemetryConfigInspection QueryWatchManaged(string path, JsonObject config) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.QueryWatchManaged, config); + } + + internal static RepoLocalTelemetryConfigInspection NotQueryWatchManaged(string path, JsonObject config) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged, config); + } + + internal static RepoLocalTelemetryConfigInspection Unreadable(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Unreadable, config: null); + } + + internal static RepoLocalTelemetryConfigInspection InvalidJson(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.InvalidJson, config: null); + } + + internal static RepoLocalTelemetryConfigInspection TooLarge(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.TooLarge, config: null); + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs index 572f4ee..df71e69 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs @@ -1,6 +1,5 @@ // Copyright (c) KeelMatrix -using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -11,10 +10,6 @@ namespace KeelMatrix.QueryWatch.Cli.Telemetry { internal static class TelemetryCommandHandler { - private const string RepositoryConfigFileName = "keelmatrix.telemetry.json"; - private const int MaxRepositoryConfigBytes = 16 * 1024; - private const string QwatchManagedPropertyName = "qwatchManaged"; - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -40,7 +35,7 @@ await Console.Error.WriteLineAsync( } private static async Task ExecuteStatusAsync(string repoRoot, bool json) { - RepositoryTelemetryStatus status = GetStatus(repoRoot); + TelemetryStatusResult status = GetStatus(repoRoot); if (json) { await Console.Out.WriteLineAsync(JsonSerializer.Serialize(status, JsonOptions)).ConfigureAwait(false); @@ -53,29 +48,36 @@ private static async Task ExecuteStatusAsync(string repoRoot, bool json) { } private static async Task ExecuteDisableAsync(string repoRoot) { - string path = Path.Combine(repoRoot, RepositoryConfigFileName); - await WriteDisabledConfigAsync(path).ConfigureAwait(false); + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + string? failureMessage = GetDisableFailureMessage(configInspection); + if (failureMessage is not null) { + await Console.Error.WriteLineAsync(failureMessage).ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } - RepositoryTelemetryStatus status = GetStatus(repoRoot); - await Console.Out.WriteLineAsync($"Repo-local qwatch-managed telemetry opt-out written: {path}").ConfigureAwait(false); - await WriteEffectiveStatusAsync(status).ConfigureAwait(false); + await WriteDisabledConfigAsync(configInspection).ConfigureAwait(false); + + TelemetryStatusResult status = GetStatus(repoRoot); + await Console.Out.WriteLineAsync( + $"Repo-local qwatch-managed telemetry opt-out written: {configInspection.Path}") + .ConfigureAwait(false); + await WriteEffectiveStatusAsync(status.EffectiveStatus).ConfigureAwait(false); return ExitCodes.Ok; } private static async Task ExecuteEnableAsync(string repoRoot) { - string path = Path.Combine(repoRoot, RepositoryConfigFileName); - TelemetryConfigMutation mutation = await EnableConfigAsync(path).ConfigureAwait(false); - RepositoryTelemetryStatus status = GetStatus(repoRoot); - - string message = mutation switch { - TelemetryConfigMutation.Removed => $"Repo-local qwatch-managed telemetry opt-out removed: {path}", - TelemetryConfigMutation.RewrittenEnabled => $"Repo-local qwatch-managed telemetry config updated: {path}", - TelemetryConfigMutation.NotQwatchManaged => $"Existing repo-local telemetry config is not qwatch-managed and was left unchanged: {path}", - _ => "No qwatch-managed repo-local telemetry opt-out was found." - }; + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + string? failureMessage = GetEnableFailureMessage(configInspection); + if (failureMessage is not null) { + await Console.Error.WriteLineAsync(failureMessage).ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } + + string message = await EnableConfigAsync(configInspection).ConfigureAwait(false); + TelemetryStatusResult status = GetStatus(repoRoot); await Console.Out.WriteLineAsync(message).ConfigureAwait(false); - await WriteEffectiveStatusAsync(status).ConfigureAwait(false); + await WriteEffectiveStatusAsync(status.EffectiveStatus).ConfigureAwait(false); return ExitCodes.Ok; } @@ -90,158 +92,106 @@ private static async Task WriteEffectiveStatusAsync(RepositoryTelemetryStatus st await Console.Out.WriteLineAsync($"Variable: {status.WinningVariableName}").ConfigureAwait(false); } - private static string FormatHumanStatus(RepositoryTelemetryStatus status) { + private static string FormatHumanStatus(TelemetryStatusResult status) { + RepositoryTelemetryStatus effectiveStatus = status.EffectiveStatus; StringBuilder sb = new(); - _ = sb.AppendLine($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}"); - _ = sb.AppendLine($"Source: {FormatSource(status.WinningSourceKind)}"); + _ = sb.AppendLine($"Telemetry: {(effectiveStatus.IsEnabled ? "enabled" : "disabled")}"); + _ = sb.AppendLine($"Source: {FormatSource(effectiveStatus.WinningSourceKind)}"); - if (!string.IsNullOrEmpty(status.WinningPath)) - _ = sb.AppendLine($"Path: {status.WinningPath}"); + if (!string.IsNullOrEmpty(effectiveStatus.WinningPath)) + _ = sb.AppendLine($"Path: {effectiveStatus.WinningPath}"); - if (!string.IsNullOrEmpty(status.WinningVariableName)) - _ = sb.AppendLine($"Variable: {status.WinningVariableName}"); + if (!string.IsNullOrEmpty(effectiveStatus.WinningVariableName)) + _ = sb.AppendLine($"Variable: {effectiveStatus.WinningVariableName}"); - _ = sb.AppendLine($"Scope: {FormatScope(status.Scope)}"); - _ = sb.AppendLine($"Repo: {status.RepoRoot}"); + _ = sb.AppendLine($"Scope: {FormatScope(effectiveStatus.Scope)}"); + _ = sb.AppendLine($"Repo: {effectiveStatus.RepoRoot}"); + _ = sb.AppendLine($"Repo-local config: {FormatRepoLocalConfigState(status.RepoLocalConfigState)}"); + _ = sb.AppendLine($"Repo-local config path: {status.RepoLocalConfigPath}"); return sb.ToString().TrimEnd(); } - private static RepositoryTelemetryStatus GetStatus(string repoRoot) { - return RepositoryTelemetry.GetEffectiveStatus(repoRoot); + private static TelemetryStatusResult GetStatus(string repoRoot) { + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + return new TelemetryStatusResult( + RepositoryTelemetry.GetEffectiveStatus(repoRoot), + configInspection.StateKind, + configInspection.Path); } - private static async Task WriteDisabledConfigAsync(string path) { - JsonObject config = ReadConfigObject(path); - RemovePropertyCaseInsensitive(config, "disabled"); - RemovePropertyCaseInsensitive(config, QwatchManagedPropertyName); - config["disabled"] = true; - config[QwatchManagedPropertyName] = true; + private static async Task WriteDisabledConfigAsync(RepoLocalTelemetryConfigInspection configInspection) { + JsonObject config = configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.Missing => RepoLocalTelemetryConfigInspector.CreateNewManagedOptOutConfig(), + RepoLocalTelemetryConfigStateKind.QueryWatchManaged => configInspection.Config!.DeepClone().AsObject(), + _ => throw new InvalidOperationException("Disable should only write when the config is missing or qwatch-managed.") + }; + + RepoLocalTelemetryConfigInspector.ApplyManagedOptOut(config); string json = JsonSerializer.Serialize(config, JsonOptions); - await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + await File.WriteAllTextAsync(configInspection.Path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); } - private static async Task EnableConfigAsync(string path) { - if (!File.Exists(path)) - return TelemetryConfigMutation.None; + private static async Task EnableConfigAsync(RepoLocalTelemetryConfigInspection configInspection) { + if (configInspection.StateKind == RepoLocalTelemetryConfigStateKind.Missing) + return $"No repo-local telemetry config exists: {configInspection.Path}"; - JsonObject? config = TryReadExistingConfigObject(path); - if (config is null || !IsQwatchManaged(config)) - return TelemetryConfigMutation.NotQwatchManaged; - - bool hadDisabled = TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName); + JsonObject config = configInspection.Config!.DeepClone().AsObject(); + RepoLocalTelemetryConfigInspector.NormalizeManagedMarker(config); + bool hadDisabled = RepoLocalTelemetryConfigInspector.TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName); if (hadDisabled) config.Remove(propertyName!); - if (HasOnlyQwatchManagedMarker(config)) { - File.Delete(path); - return TelemetryConfigMutation.Removed; + if (hadDisabled && HasOnlyQueryWatchManagedMarker(config)) { + File.Delete(configInspection.Path); + return $"Repo-local qwatch-managed telemetry opt-out removed: {configInspection.Path}"; } if (!hadDisabled) - return TelemetryConfigMutation.None; + return $"No qwatch-managed repo-local telemetry opt-out was found: {configInspection.Path}"; string json = JsonSerializer.Serialize(config, JsonOptions); - await File.WriteAllTextAsync(path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); - return TelemetryConfigMutation.RewrittenEnabled; - } - - private static JsonObject ReadConfigObject(string path) { - JsonObject? existing = TryReadExistingConfigObject(path); - return existing ?? []; - } - - private static JsonObject? TryReadExistingConfigObject(string path) { - if (!TryReadTextFileCapped(path, MaxRepositoryConfigBytes, out string text)) - return null; - - try { - return JsonNode.Parse(text) as JsonObject; - } - catch { - return null; - } - } - - private static bool TryReadTextFileCapped(string path, int maxBytes, out string text) { - text = string.Empty; - - try { - FileInfo fileInfo = new(path); - if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > maxBytes) - return false; - - text = File.ReadAllText(path); - return text.Length > 0; - } - catch { - return false; - } - } - - private static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) { - KeyValuePair property = obj - .FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase)); - if (property.Key is not null) { - propertyName = property.Key; - return true; - } - - propertyName = null; - return false; - } - - private static void RemovePropertyCaseInsensitive(JsonObject obj, string name) { - if (TryGetPropertyNameCaseInsensitive(obj, name, out string? propertyName)) - obj.Remove(propertyName!); - } - - private static bool IsQwatchManaged(JsonObject config) { - if (!TryGetPropertyNameCaseInsensitive(config, QwatchManagedPropertyName, out string? propertyName)) - return false; - - return IsTruthyJsonNode(config[propertyName!]); - } - - private static bool HasOnlyQwatchManagedMarker(JsonObject config) { - return config.Count == 1 && IsQwatchManaged(config); + await File.WriteAllTextAsync(configInspection.Path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + return $"Repo-local qwatch-managed telemetry config updated: {configInspection.Path}"; + } + + private static string? GetDisableFailureMessage(RepoLocalTelemetryConfigInspection configInspection) { + return configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => + $"Refusing to overwrite existing repo-local telemetry config because it is not qwatch-managed: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.Unreadable => + $"Refusing to overwrite existing repo-local telemetry config because it could not be read: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.InvalidJson => + $"Refusing to overwrite existing repo-local telemetry config because it contains invalid JSON: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.TooLarge => + $"Refusing to overwrite existing repo-local telemetry config because it exceeds the {RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes}-byte inspection limit: {configInspection.Path}", + _ => null + }; } - private static bool IsTruthyJsonNode(JsonNode? node) { - if (node is null) - return false; - - try { - using JsonDocument doc = JsonDocument.Parse(node.ToJsonString()); - return doc.RootElement.ValueKind switch { - JsonValueKind.True => true, - JsonValueKind.String => IsTruthyValue(doc.RootElement.GetString()), - JsonValueKind.Number => IsTruthyValue(doc.RootElement.GetRawText()), - _ => false - }; - } - catch { - return false; - } + private static string? GetEnableFailureMessage(RepoLocalTelemetryConfigInspection configInspection) { + return configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => + $"Existing repo-local telemetry config is not qwatch-managed and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.Unreadable => + $"Existing repo-local telemetry config could not be read and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.InvalidJson => + $"Existing repo-local telemetry config contains invalid JSON and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.TooLarge => + $"Existing repo-local telemetry config exceeds the {RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes}-byte inspection limit and was left unchanged: {configInspection.Path}", + _ => null + }; } - private static bool IsTruthyValue(string? value) { - if (string.IsNullOrWhiteSpace(value)) - return false; - - string normalized = value.Trim(); - return normalized == "1" - || normalized.Equals("true", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("y", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("on", StringComparison.OrdinalIgnoreCase); + private static bool HasOnlyQueryWatchManagedMarker(JsonObject config) { + return config.Count == 1 && RepoLocalTelemetryConfigInspector.IsManagedByQueryWatch(config); } private static string FormatSource(RepositoryTelemetrySourceKind sourceKind) { return sourceKind switch { RepositoryTelemetrySourceKind.None => "none", RepositoryTelemetrySourceKind.ProcessEnvironment => "process environment", - RepositoryTelemetrySourceKind.RepositoryConfig => RepositoryConfigFileName, + RepositoryTelemetrySourceKind.RepositoryConfig => RepoLocalTelemetryConfigInspector.RepositoryConfigFileName, RepositoryTelemetrySourceKind.DotEnvLocal => ".env.local", RepositoryTelemetrySourceKind.DotEnv => ".env", _ => "none" @@ -257,11 +207,25 @@ private static string FormatScope(RepositoryTelemetryScope scope) { }; } - private enum TelemetryConfigMutation { - None, - Removed, - RewrittenEnabled, - NotQwatchManaged + private static string FormatRepoLocalConfigState(RepoLocalTelemetryConfigStateKind stateKind) { + return stateKind switch { + RepoLocalTelemetryConfigStateKind.Missing => "missing", + RepoLocalTelemetryConfigStateKind.QueryWatchManaged => "qwatch-managed", + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => "not qwatch-managed", + RepoLocalTelemetryConfigStateKind.Unreadable => "unreadable", + RepoLocalTelemetryConfigStateKind.InvalidJson => "invalid JSON", + RepoLocalTelemetryConfigStateKind.TooLarge => "exceeds inspection size cap", + _ => "missing" + }; } } + + internal sealed class TelemetryStatusResult( + RepositoryTelemetryStatus effectiveStatus, + RepoLocalTelemetryConfigStateKind repoLocalConfigState, + string repoLocalConfigPath) { + public RepositoryTelemetryStatus EffectiveStatus { get; } = effectiveStatus; + public RepoLocalTelemetryConfigStateKind RepoLocalConfigState { get; } = repoLocalConfigState; + public string RepoLocalConfigPath { get; } = repoLocalConfigPath; + } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs b/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs new file mode 100644 index 0000000..a0d1886 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs @@ -0,0 +1,30 @@ +// Copyright (c) KeelMatrix + +using KeelMatrix.Telemetry; + +namespace KeelMatrix.QueryWatch.Cli { + internal interface ITelemetryHost { + void TrackActivation(); + } + + internal sealed class TelemetryHostClient : ITelemetryHost { + private static readonly Client Client = new("qwatchCLI", typeof(Program)); + + public void TrackActivation() { + Client.TrackActivation(); + } + } + + internal static class TelemetryHost { + private static ITelemetryHost current = new TelemetryHostClient(); + + internal static void TrackActivation() { + Volatile.Read(ref current).TrackActivation(); + } + + internal static ITelemetryHost Current { + get => Volatile.Read(ref current); + set => Volatile.Write(ref current, value ?? throw new ArgumentNullException(nameof(value))); + } + } +} From ec0e9ce1577da3c5bdde0fb3e276d5da2b66a56b Mon Sep 17 00:00:00 2001 From: KeelMatrix Date: Thu, 2 Apr 2026 10:44:04 +0700 Subject: [PATCH 4/4] Fix telemetry CLI test path assertions on macOS --- .../TelemetryCommandTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs index 3e36810..6d83106 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs @@ -49,7 +49,7 @@ public void Telemetry_Status_Shows_Enabled_When_No_Overrides_Exist() { _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); _ = stdout.Should().Contain("Telemetry: enabled"); _ = stdout.Should().Contain("Source: none"); - _ = stdout.Should().Contain($"Repo: {repo.Root}"); + _ = NormalizeForAssertion(stdout).Should().Contain($"Repo: {NormalizeForAssertion(repo.Root)}"); _ = stdout.Should().Contain("Repo-local config: missing"); } @@ -82,7 +82,7 @@ public void Telemetry_Status_Shows_Disabled_When_Repository_Config_Disables() { _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); _ = stdout.Should().Contain("Telemetry: disabled"); _ = stdout.Should().Contain("Source: keelmatrix.telemetry.json"); - _ = stdout.Should().Contain(Path.Combine(repo.Root, "keelmatrix.telemetry.json")); + _ = NormalizeForAssertion(stdout).Should().Contain(NormalizeForAssertion(Path.Combine(repo.Root, "keelmatrix.telemetry.json"))); _ = stdout.Should().Contain("Scope: repo-local"); _ = stdout.Should().Contain("Repo-local config: not qwatch-managed"); } @@ -171,12 +171,12 @@ public void Telemetry_Status_Json_Uses_Status_Model() { JsonElement effectiveStatus = doc.RootElement.GetProperty("effectiveStatus"); _ = effectiveStatus.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); _ = effectiveStatus.GetProperty("winningSourceKind").GetString().Should().Be("dotEnvLocal"); - _ = effectiveStatus.GetProperty("winningPath").GetString().Should().Be(Path.Combine(repo.Root, ".env.local")); + _ = NormalizeForAssertion(effectiveStatus.GetProperty("winningPath").GetString()).Should().Be(NormalizeForAssertion(Path.Combine(repo.Root, ".env.local"))); _ = effectiveStatus.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); _ = effectiveStatus.GetProperty("scope").GetString().Should().Be("repoLocal"); - _ = effectiveStatus.GetProperty("repoRoot").GetString().Should().Be(repo.Root); + _ = NormalizeForAssertion(effectiveStatus.GetProperty("repoRoot").GetString()).Should().Be(NormalizeForAssertion(repo.Root)); _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("missing"); - _ = doc.RootElement.GetProperty("repoLocalConfigPath").GetString().Should().Be(Path.Combine(repo.Root, "keelmatrix.telemetry.json")); + _ = NormalizeForAssertion(doc.RootElement.GetProperty("repoLocalConfigPath").GetString()).Should().Be(NormalizeForAssertion(Path.Combine(repo.Root, "keelmatrix.telemetry.json"))); } [Fact] @@ -517,6 +517,15 @@ public void Dispose() { } } + private static string NormalizeForAssertion(string? value) { + value.Should().NotBeNull(); + + if (!OperatingSystem.IsMacOS()) + return value!; + + return value!.Replace("/private/var/", "/var/", StringComparison.Ordinal); + } + private sealed class EnvironmentVariableSnapshot : IDisposable { private readonly (string Name, string? Value)[] snapshot;