diff --git a/CodeyBox.slnx b/CodeyBox.slnx index ababf0e0..68ef9f5d 100644 --- a/CodeyBox.slnx +++ b/CodeyBox.slnx @@ -41,6 +41,7 @@ + diff --git a/docs/extending/auditor-plugins.md b/docs/extending/auditor-plugins.md index 6541138e..869eda55 100644 --- a/docs/extending/auditor-plugins.md +++ b/docs/extending/auditor-plugins.md @@ -233,6 +233,15 @@ GET /plugins → [{ "pluginId": "myorg.no-var-keyword", "displayName": "MyOrg: Ban var keyword" }] ``` +## Test-runner auditors + +Test execution is a specialised plugin shape: `ITestRunnerAuditor` +(`CodeyBox.Core`) owns invocation building, test-universe enumeration, and +result classification for one framework. The bundled `dotnet test` runner and +a pytest reference stub ship in `plugins/CodeyBox.DotnetTestRunnerPlugin/`. +See [`docs/extending/test-runner-plugins.md`](test-runner-plugins.md) for the +packaging, the peer-runner recipe, and the bundling caveat. + ## Sample plugin A fully working sample is provided at `samples/CodeyBox.SampleAuditorPlugin/`. diff --git a/docs/extending/plugins.md b/docs/extending/plugins.md index 75024337..4e1d717f 100644 --- a/docs/extending/plugins.md +++ b/docs/extending/plugins.md @@ -210,7 +210,7 @@ the operator explicitly opts in. This is intentional. Every plugin declares the minimum host API version it requires via `minHostApiVersion` on `[CodeyBoxPlugin]`. The host rejects plugins that -require a version newer than `CodeyBoxApiVersion.Current` (currently `"1.1"`). +require a version newer than `CodeyBoxApiVersion.Current` (currently `"1.3"`). ### Version bump rules diff --git a/docs/extending/test-runner-plugins.md b/docs/extending/test-runner-plugins.md new file mode 100644 index 00000000..5890c562 --- /dev/null +++ b/docs/extending/test-runner-plugins.md @@ -0,0 +1,84 @@ +# Test-runner plugins + +The merge gate's test step is an `ITestRunnerAuditor` (`CodeyBox.Core`), not a +bespoke host service. The bundled `dotnet test` runner ships as a plugin +package — `plugins/CodeyBox.DotnetTestRunnerPlugin/` — so `pytest`, `go test`, +and `cargo test` can be peer `ITestRunnerAuditor` plugins without touching +`CodeyBox.Core`. `TestFramework` already declares all four members. + +This is a packaging seam, not a behavioural one: the default host still +registers the dotnet runner explicitly (same byte-identical command, same +soundness tests), with or without any allowlist entry. + +## Contents + +- [`DotnetTestAuditor`](#dotnettestauditor) — the bundled implementation +- [`DotnetTestRunner`](#dotnettestrunner) — the attributed plugin entry +- [`PytestTestRunner`](#pytesttestrunner) — the reference stub +- [Writing a peer runner](#writing-a-peer-runner) +- [Bundling caveat](#bundling-caveat) + +## `DotnetTestAuditor` + +Lives in the plugin package (`CodeyBox.DotnetTestRunnerPlugin` namespace). +Owns the full `dotnet test` invocation — base command, test-selection +`--filter`, `--blame-hang` args — carries its own result classifier, and +delegates each run to `ShellCommandAuditor` so tool-presence handling stays +identical to the generic shell path. With an all-tests selection and default +options the command is byte-identical to `["dotnet", "test", "--no-build"]`. + +The host default-registers one instance (`csharp:test-pass`, build-test-gate +role) in `Program.cs`, and the `csharp` language preset builds per-project +instances with the same hot-reloadable run options. The VSTest escaping guard +(`VstestFilterEscaping`) and the output parser (`DotnetTestOutputParser`) stay +in `CodeyBox.Audit.Shell` as the single shared copy both the plugin and the +shell attribution path reuse. + +## `DotnetTestRunner` + +The `[CodeyBoxPlugin]` entry (`id: "codeybox.dotnet-test-runner"`). A thin, +parameterless-constructible wrapper over the canonical `csharp:test-pass` +configuration — parameterless because the plugin loader registers entry types +without constructor arguments. + +Run options are static per load: `BlameHangTimeout` / `AuditorIdleTimeout` +under `CodeyBox:Plugins:codeybox.dotnet-test-runner:` are read once in +`InitializeAsync`. Invalid values log a warning and keep +`TestRunOptions.Default`. The host's default registration (live +`Func`) remains the hot-reloadable path. + +## `PytestTestRunner` + +A reference-only stub (`id: "codeybox.pytest-test-runner"`) proving a second +framework needs no Core change: a real `TestSuiteDescriptor` +(`pytest --collect-only -q`) and a real `BuildInvocation` shape (bare `pytest` +for the whole suite, `pytest -k ` when narrowed). `RunAsync` +deliberately throws `NotSupportedException` instead of returning a fabricated +pass, so the stub can never green a gate it did not run. It is not referenced +by any catalog or DI registration. A production pytest runner would replace the +throw with a sandboxed `pytest` invocation plus a pytest output classifier. + +## Writing a peer runner + +1. Reference `CodeyBox.Core` + `CodeyBox.PluginSdk` only (never the + orchestrator or API). The pytest stub is the template: it needs nothing + else. +2. Implement `ITestRunnerAuditor`: a stable `Name`, the `TestSuiteDescriptor` + (framework + enumeration argv the selector reasons about), `BuildInvocation` + (apply the narrowed `TestSelection` through runner-native filter syntax — + never raw passthrough of untrusted baseline names), and a `ResultClassifier` + that distinguishes genuine test failures from an unrunnable environment. +3. Decorate with `[CodeyBoxPlugin]` and a new `TestFramework` member only if + the framework is not already declared. `Pytest`, `GoTest`, and `CargoTest` + are pre-declared. +4. Keep the runner out of the default panel until the host gains multi-runner + selection: the single-`ITestRunnerAuditor` default registration assumes one + canonical runner. + +## Bundling caveat + +The dotnet entry is bundled AND default-registered. Do not add +`codeybox.dotnet-test-runner` to the allowlist of a host that already +default-registers it — the discovered copy would join the audit panel as a +second test gate alongside the default one. The plugin id exists so downstream +hosts can load this package as an external plugin instead of referencing it. diff --git a/docs/quality/audit.md b/docs/quality/audit.md index 0255538e..a795b23c 100644 --- a/docs/quality/audit.md +++ b/docs/quality/audit.md @@ -230,7 +230,7 @@ non-zero on findings. Capability: `None`. -### `DotnetTestAuditor` (`CodeyBox.Audit.Shell`) +### `DotnetTestAuditor` (`CodeyBox.DotnetTestRunnerPlugin`) Backs the built-in `csharp:test-pass` gate. It is a first-class `ITestRunnerAuditor` rather than a generic `ShellCommandAuditor`, so diff --git a/plugins/CodeyBox.DotnetTestRunnerPlugin/CodeyBox.DotnetTestRunnerPlugin.csproj b/plugins/CodeyBox.DotnetTestRunnerPlugin/CodeyBox.DotnetTestRunnerPlugin.csproj new file mode 100644 index 00000000..7b0adf43 --- /dev/null +++ b/plugins/CodeyBox.DotnetTestRunnerPlugin/CodeyBox.DotnetTestRunnerPlugin.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/src/CodeyBox.Audit.Shell/DotnetTestAuditor.cs b/plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestAuditor.cs similarity index 98% rename from src/CodeyBox.Audit.Shell/DotnetTestAuditor.cs rename to plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestAuditor.cs index 967e8fb3..8aa26f5a 100644 --- a/src/CodeyBox.Audit.Shell/DotnetTestAuditor.cs +++ b/plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestAuditor.cs @@ -1,13 +1,15 @@ using System.Globalization; using System.Text.Json; -using CodeyBox.Audit; +using CodeyBox.Audit.Shell; using CodeyBox.Core; -namespace CodeyBox.Audit.Shell; +namespace CodeyBox.DotnetTestRunnerPlugin; /// -/// First-class dotnet test auditor. Replaces the previous arrangement -/// where csharp:test-pass was a generic +/// First-class dotnet test auditor, shipped as a CodeyBox plugin (see +/// , the attributed entry point). It replaces +/// the previous arrangement where csharp:test-pass was a generic +/// /// that three separate call sites had to sniff as "really a dotnet test" /// (result-classifier selection by argv[1]=="test", per-test hang /// handling, and a future --filter injection). diff --git a/plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestRunner.cs b/plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestRunner.cs new file mode 100644 index 00000000..111b9560 --- /dev/null +++ b/plugins/CodeyBox.DotnetTestRunnerPlugin/DotnetTestRunner.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using CodeyBox.Audit.Shell; +using CodeyBox.Core; +using CodeyBox.PluginSdk; +using Microsoft.Extensions.Logging; + +namespace CodeyBox.DotnetTestRunnerPlugin; + +/// +/// Plugin entry point for the bundled dotnet test runner. A thin, +/// parameterless-constructible over the shared +/// implementation, so the plugin loader (which +/// registers entry types without constructor arguments) can load this package +/// while the host keeps default-registering the implementation directly with +/// live hot-reloadable options. +/// +/// Do NOT add this plugin id to the allowlist of a host that already +/// default-registers the dotnet runner (this repository's host does): the +/// discovered copy would join the audit panel as a second test gate alongside +/// the default one. The id exists so downstream hosts can load this package as +/// an external plugin instead of referencing it. +/// +/// Run options are static per load (not hot-reloadable): the optional +/// BlameHangTimeout / AuditorIdleTimeout scoped-config values are +/// read once in . An invalid value logs a warning +/// and keeps — a bad config value must +/// never break plugin load, mirroring the auditor's fail-safe mode fallback. +/// The host's default registration (live Func{TestRunOptions}) is the +/// hot-reloadable path. Test selection, failure attribution, and shadow +/// telemetry are not wired on the standalone entry, so its runs always execute +/// the full suite (the fail-safe default). +/// +[CodeyBoxPlugin( + id: PluginId, + displayName: "CodeyBox: dotnet test runner", + minHostApiVersion: "1.3")] +public sealed class DotnetTestRunner : ITestRunnerAuditor, IShellAuditorArgvProvider, IPluginInitializer +{ + /// Allowlist / config-scope id for this plugin. + public const string PluginId = "codeybox.dotnet-test-runner"; + + /// Runner name, identical to the host's default registration. + public const string RunnerName = "csharp:test-pass"; + + /// Canonical base command the selection seam enumerates against. + public static readonly IReadOnlyList CanonicalBaseArgv = ["dotnet", "test", "--no-build"]; + + private TestRunOptions _runOptions = TestRunOptions.Default; + private ILogger _logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + /// + /// Parameterless for the plugin loader. Builds the canonical + /// csharp:test-pass build-test-gate runner over + /// until + /// optionally narrows it from scoped config. + /// + public DotnetTestRunner() + { + } + + public string Name => RunnerName; + + public string Kind => "shell"; + + public AuditCapabilities Required => AuditCapabilities.None; + + public bool CanShortCircuitOnBlockingFinding => true; + + public AuditorRole Role => AuditorRole.BuildTestGate; + + public BuildTestGateEvidence BuildTestGateEvidence => BuildTestGateEvidence.Test; + + public TestSuiteDescriptor TestSuite => + new(TestFramework.DotnetTest, [.. CanonicalBaseArgv, "--list-tests"]); + + public IAuditResultClassifier ResultClassifier { get; } = new DotnetTestCommandResultClassifier(); + + public TestRunOptions CurrentRunOptions => _runOptions; + + public IReadOnlyList Argv => BuildInner().Argv; + + public IReadOnlyList BuildInvocation(TestSelection selection, TestRunOptions options) + => BuildInner().BuildInvocation(selection, options); + + public Task RunAsync( + ISandbox sandbox, + string workingDirectory, + AuditContext context, + CancellationToken ct = default) + => BuildInner().RunAsync(sandbox, workingDirectory, context, ct); + + public Task InitializeAsync(PluginContext context, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + _logger = context.Logger; + var blameHang = ParseOptionalTimeout(context.ScopedConfig["BlameHangTimeout"], "BlameHangTimeout"); + var idle = ParseOptionalTimeout(context.ScopedConfig["AuditorIdleTimeout"], "AuditorIdleTimeout"); + if (blameHang is null && idle is null) + return Task.CompletedTask; + _runOptions = new TestRunOptions + { + BlameHangTimeout = blameHang ?? _runOptions.BlameHangTimeout, + IdleTimeout = idle ?? _runOptions.IdleTimeout, + }; + _logger.LogInformation( + "DotnetTestRunner initialized: blameHang={BlameHang} idleTimeout={IdleTimeout}", + _runOptions.BlameHangTimeout, _runOptions.IdleTimeout); + return Task.CompletedTask; + } + + private TimeSpan? ParseOptionalTimeout(string? value, string key) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed) && parsed > TimeSpan.Zero) + return parsed; + _logger.LogWarning( + "DotnetTestRunner: ignoring invalid {Key}={Value}; keeping the current run options", + key, value); + return null; + } + + private DotnetTestAuditor BuildInner() + => new(new DotnetTestAuditorOptions + { + Name = RunnerName, + BaseArgv = CanonicalBaseArgv, + CanShortCircuitOnBlockingFinding = true, + Role = AuditorRole.BuildTestGate, + BuildTestGateEvidence = BuildTestGateEvidence.Test, + RunOptionsAccessor = () => _runOptions, + SelfHealNuGetHome = true, + }); +} diff --git a/plugins/CodeyBox.DotnetTestRunnerPlugin/PytestTestRunner.cs b/plugins/CodeyBox.DotnetTestRunnerPlugin/PytestTestRunner.cs new file mode 100644 index 00000000..6a82bd2d --- /dev/null +++ b/plugins/CodeyBox.DotnetTestRunnerPlugin/PytestTestRunner.cs @@ -0,0 +1,84 @@ +using CodeyBox.Core; +using CodeyBox.PluginSdk; + +namespace CodeyBox.DotnetTestRunnerPlugin; + +/// +/// Reference-only stub demonstrating the seam +/// for a second framework. It carries a real +/// and a real shape (pytest, narrowing via +/// -k), but is intentionally unimplemented: this +/// host has no Python projects and the stub is NOT registered in any catalog or +/// DI container. A production pytest runner would replace the throw with a +/// sandboxed pytest invocation (mirroring how +/// delegates to ShellCommandAuditor) +/// plus a pytest output classifier — without touching CodeyBox.Core. +/// +[CodeyBoxPlugin( + id: PluginId, + displayName: "CodeyBox: pytest test runner (reference stub)", + minHostApiVersion: "1.3")] +public sealed class PytestTestRunner : ITestRunnerAuditor +{ + /// Allowlist / config-scope id for this stub. + public const string PluginId = "codeybox.pytest-test-runner"; + + /// + public string Name => "pytest:test-pass"; + + /// + public string Kind => "shell"; + + /// + public AuditCapabilities Required => AuditCapabilities.None; + + /// + public TestSuiteDescriptor TestSuite => + new(TestFramework.Pytest, ["pytest", "--collect-only", "-q"]); + + /// + public IAuditResultClassifier ResultClassifier { get; } = new PytestPassthroughClassifier(); + + /// + public TestRunOptions CurrentRunOptions => TestRunOptions.Default; + + /// + /// Builds the pytest argv: bare pytest for the whole suite, + /// otherwise pytest -k <expr> with the selected filters + /// or-joined. pytest -k treats each entry as a substring + /// match, so entries are passed through verbatim (no VSTest escaping — + /// that guard belongs to the dotnet runner only). + /// + public IReadOnlyList BuildInvocation(TestSelection selection, TestRunOptions options) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(options); + if (selection.IsAll) + return ["pytest"]; + return ["pytest", "-k", string.Join(" or ", selection.Filters)]; + } + + /// + /// Not implemented by design: this is a reference stub, never registered. + /// Always throws rather than returning + /// a fabricated pass so the stub can never green a gate it did not run. + /// + public Task RunAsync( + ISandbox sandbox, + string workingDirectory, + AuditContext context, + CancellationToken ct = default) + => throw new NotSupportedException( + "PytestTestRunner is a reference stub demonstrating the ITestRunnerAuditor seam; " + + "it is not registered and cannot execute a test run."); + + /// + /// No refinement: without a real pytest output grammar there is nothing + /// sound to classify, so failed commands fall back to the generic + /// command-failure result. + /// + private sealed class PytestPassthroughClassifier : IAuditResultClassifier + { + public AuditResult? ClassifyFailedCommand(AuditResultClassificationContext context) => null; + } +} diff --git a/src/CodeyBox.Api/CodeyBox.Api.csproj b/src/CodeyBox.Api/CodeyBox.Api.csproj index 8e919381..c84535ee 100644 --- a/src/CodeyBox.Api/CodeyBox.Api.csproj +++ b/src/CodeyBox.Api/CodeyBox.Api.csproj @@ -21,6 +21,7 @@ + diff --git a/src/CodeyBox.Api/Program.cs b/src/CodeyBox.Api/Program.cs index 3b7c6c39..30921488 100644 --- a/src/CodeyBox.Api/Program.cs +++ b/src/CodeyBox.Api/Program.cs @@ -22,6 +22,7 @@ using CodeyBox.Audit.Llm.PlanAudit; using CodeyBox.Audit.Presets; using CodeyBox.Audit.Shell; +using CodeyBox.DotnetTestRunnerPlugin; using CodeyBox.Core; using CodeyBox.Deployment; using CodeyBox.Git; diff --git a/src/CodeyBox.Audit.Presets/CodeyBox.Audit.Presets.csproj b/src/CodeyBox.Audit.Presets/CodeyBox.Audit.Presets.csproj index 353cd5ae..2e7c96ac 100644 --- a/src/CodeyBox.Audit.Presets/CodeyBox.Audit.Presets.csproj +++ b/src/CodeyBox.Audit.Presets/CodeyBox.Audit.Presets.csproj @@ -5,6 +5,7 @@ + diff --git a/src/CodeyBox.Audit.Presets/Presets/LanguagePresetHelpers.cs b/src/CodeyBox.Audit.Presets/Presets/LanguagePresetHelpers.cs index fabc4080..2e535bde 100644 --- a/src/CodeyBox.Audit.Presets/Presets/LanguagePresetHelpers.cs +++ b/src/CodeyBox.Audit.Presets/Presets/LanguagePresetHelpers.cs @@ -1,5 +1,6 @@ using CodeyBox.Audit.Shell; using CodeyBox.Core; +using CodeyBox.DotnetTestRunnerPlugin; namespace CodeyBox.Audit.Presets.Presets; diff --git a/src/CodeyBox.Audit.Shell/DotnetTestOutputParser.cs b/src/CodeyBox.Audit.Shell/DotnetTestOutputParser.cs index 751abc08..bcc6d3b6 100644 --- a/src/CodeyBox.Audit.Shell/DotnetTestOutputParser.cs +++ b/src/CodeyBox.Audit.Shell/DotnetTestOutputParser.cs @@ -4,7 +4,12 @@ namespace CodeyBox.Audit.Shell; -internal static class DotnetTestOutputParser +/// +/// Parses dotnet test output into per-test failures. Public because the +/// plugin-packaged test runner reuses it from outside this assembly; the +/// implementation stays here so there is exactly one copy. +/// +public static class DotnetTestOutputParser { private const double UnrunnableFailureThresholdMs = 50; private const int MaxReportedFailureFindings = 50; @@ -238,7 +243,11 @@ private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max] + "..."; } -internal sealed record DotnetTestOutputParseResult( +/// +/// Parsed outcome of a dotnet test run. Public alongside +/// (same single-source-of-truth reason). +/// +public sealed record DotnetTestOutputParseResult( IReadOnlyList Findings, IReadOnlyList FailedTestNames, int ParsedFailureCount, diff --git a/src/CodeyBox.Audit.Shell/VstestFilterEscaping.cs b/src/CodeyBox.Audit.Shell/VstestFilterEscaping.cs index 1067b6e1..a54942e3 100644 --- a/src/CodeyBox.Audit.Shell/VstestFilterEscaping.cs +++ b/src/CodeyBox.Audit.Shell/VstestFilterEscaping.cs @@ -7,8 +7,12 @@ namespace CodeyBox.Audit.Shell; /// test-selection baseline, which is untrusted sandbox-produced input. VSTest /// treats \ , ( ) ! ~ & | = as filter metacharacters, so each is /// backslash-escaped; anything else (including whitespace) is literal. +/// +/// Public because the plugin-packaged test runner reuses this guard from +/// outside this assembly; the implementation stays here so there is exactly +/// one copy. /// -internal static class VstestFilterEscaping +public static class VstestFilterEscaping { public static string EscapeValue(string value) { diff --git a/src/CodeyBox.Core/CodeyBoxApiVersion.cs b/src/CodeyBox.Core/CodeyBoxApiVersion.cs index cc1e38ee..1d0a8727 100644 --- a/src/CodeyBox.Core/CodeyBoxApiVersion.cs +++ b/src/CodeyBox.Core/CodeyBoxApiVersion.cs @@ -15,7 +15,7 @@ namespace CodeyBox.Core; public static class CodeyBoxApiVersion { /// Current orchestrator host API version. - public const string Current = "1.2"; + public const string Current = "1.3"; /// /// Returns true when this host satisfies the plugin's minimum version diff --git a/src/CodeyBox.Core/ITestRunnerAuditor.cs b/src/CodeyBox.Core/ITestRunnerAuditor.cs index e3ec7222..a14b6be9 100644 --- a/src/CodeyBox.Core/ITestRunnerAuditor.cs +++ b/src/CodeyBox.Core/ITestRunnerAuditor.cs @@ -53,10 +53,24 @@ public interface ITestRunnerAuditorProvider ITestRunnerAuditor? TestRunner { get; } } -/// Test frameworks a can drive. +/// +/// Test frameworks an can drive. The seam is +/// framework-agnostic: peer runners (pytest, go test, cargo test, …) implement +/// the same interface and ship as plugins without any change to this assembly. +/// public enum TestFramework { + /// dotnet test (VSTest). Bundled default merge-gate runner. DotnetTest, + + /// pytest. Peer runner; see the reference stub in the test-runner plugin package. + Pytest, + + /// go test. Peer runner (not bundled). + GoTest, + + /// cargo test. Peer runner (not bundled). + CargoTest, } /// diff --git a/src/CodeyBox.Core/ITestSelector.cs b/src/CodeyBox.Core/ITestSelector.cs index ba1008a4..8ea4f71c 100644 --- a/src/CodeyBox.Core/ITestSelector.cs +++ b/src/CodeyBox.Core/ITestSelector.cs @@ -1,9 +1,9 @@ namespace CodeyBox.Core; /// -/// Regression-test-selection seam. Given the change under review and a -/// capability (from the DotnetTestAuditor -/// foundation), a selector decides which subset of the suite the +/// Regression-test-selection seam. Given the change under review and an +/// capability (from a test-runner auditor +/// implementation), a selector decides which subset of the suite the /// csharp:test-pass audit runs — enabling a SOUND narrowing for the audit /// loop while the merge/release verification path always runs everything. /// diff --git a/src/CodeyBox.Core/TestSelectionTelemetry.cs b/src/CodeyBox.Core/TestSelectionTelemetry.cs index 2711995e..fa56143e 100644 --- a/src/CodeyBox.Core/TestSelectionTelemetry.cs +++ b/src/CodeyBox.Core/TestSelectionTelemetry.cs @@ -82,7 +82,7 @@ public sealed record TestSelectionTelemetry /// /// Pure computer behind . All methods are /// total functions of their inputs; the only impure step (reading the run's -/// mode/decision/universe) stays in DotnetTestAuditor. +/// mode/decision/universe) stays in the test-runner auditor. /// public static class TestSelectionTelemetryComputer { diff --git a/src/CodeyBox.Orchestrator/SqliteReleaseStore.cs b/src/CodeyBox.Orchestrator/SqliteReleaseStore.cs index 22f8f7e3..890f96df 100644 --- a/src/CodeyBox.Orchestrator/SqliteReleaseStore.cs +++ b/src/CodeyBox.Orchestrator/SqliteReleaseStore.cs @@ -144,21 +144,37 @@ UPDATE releases SET public async Task GetAsync(ReleaseId id, CancellationToken ct = default) { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM releases WHERE id = $id;"; - cmd.Parameters.AddWithValue("$id", id.ToString()); - using var reader = await cmd.ExecuteReaderAsync(ct); - return await reader.ReadAsync(ct) ? Read(reader) : null; + await _writeLock.WaitAsync(ct); + try + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM releases WHERE id = $id;"; + cmd.Parameters.AddWithValue("$id", id.ToString()); + using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? Read(reader) : null; + } + finally + { + _writeLock.Release(); + } } public async Task GetByNameAsync(ProjectId projectId, string name, CancellationToken ct = default) { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM releases WHERE project_id = $pid AND name = $name;"; - cmd.Parameters.AddWithValue("$pid", projectId.Value); - cmd.Parameters.AddWithValue("$name", name); - using var reader = await cmd.ExecuteReaderAsync(ct); - return await reader.ReadAsync(ct) ? Read(reader) : null; + await _writeLock.WaitAsync(ct); + try + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM releases WHERE project_id = $pid AND name = $name;"; + cmd.Parameters.AddWithValue("$pid", projectId.Value); + cmd.Parameters.AddWithValue("$name", name); + using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? Read(reader) : null; + } + finally + { + _writeLock.Release(); + } } public async Task> ListAsync( @@ -168,37 +184,45 @@ public async Task> ListAsync( int? offset = null, CancellationToken ct = default) { - using var cmd = _conn.CreateCommand(); - var conditions = new List(); - if (projectId.HasValue) - { - conditions.Add("project_id = $pid"); - cmd.Parameters.AddWithValue("$pid", projectId.Value.Value); - } - if (state.HasValue) - { - conditions.Add("state = $state"); - cmd.Parameters.AddWithValue("$state", (int)state.Value); - } - var where = conditions.Count > 0 ? " WHERE " + string.Join(" AND ", conditions) : ""; - var limitClause = ""; - if (limit.HasValue) + await _writeLock.WaitAsync(ct); + try { - limitClause = " LIMIT $limit"; - cmd.Parameters.AddWithValue("$limit", limit.Value); - if (offset.HasValue) + using var cmd = _conn.CreateCommand(); + var conditions = new List(); + if (projectId.HasValue) { - limitClause += " OFFSET $offset"; - cmd.Parameters.AddWithValue("$offset", offset.Value); + conditions.Add("project_id = $pid"); + cmd.Parameters.AddWithValue("$pid", projectId.Value.Value); } + if (state.HasValue) + { + conditions.Add("state = $state"); + cmd.Parameters.AddWithValue("$state", (int)state.Value); + } + var where = conditions.Count > 0 ? " WHERE " + string.Join(" AND ", conditions) : ""; + var limitClause = ""; + if (limit.HasValue) + { + limitClause = " LIMIT $limit"; + cmd.Parameters.AddWithValue("$limit", limit.Value); + if (offset.HasValue) + { + limitClause += " OFFSET $offset"; + cmd.Parameters.AddWithValue("$offset", offset.Value); + } + } + // nosemgrep: csharp.lang.security.sqli.csharp-sqli.csharp-sqli -- conditions/pagination built from hardcoded literals only; parameter values injected via AddWithValue + cmd.CommandText = $"SELECT * FROM releases{where} ORDER BY created_at DESC{limitClause};"; + using var reader = await cmd.ExecuteReaderAsync(ct); + var result = new List(); + while (await reader.ReadAsync(ct)) + result.Add(Read(reader)); + return result; + } + finally + { + _writeLock.Release(); } - // nosemgrep: csharp.lang.security.sqli.csharp-sqli.csharp-sqli -- conditions/pagination built from hardcoded literals only; parameter values injected via AddWithValue - cmd.CommandText = $"SELECT * FROM releases{where} ORDER BY created_at DESC{limitClause};"; - using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(Read(reader)); - return result; } /// @@ -278,32 +302,40 @@ INSERT OR IGNORE INTO release_audit_iterations public async Task> ListAuditIterationsAsync(ReleaseId releaseId, CancellationToken ct = default) { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = "SELECT * FROM release_audit_iterations WHERE release_id = $rid ORDER BY iteration ASC;"; - cmd.Parameters.AddWithValue("$rid", releaseId.ToString()); - using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) + await _writeLock.WaitAsync(ct); + try { - var findingsJson = reader.GetString(reader.GetOrdinal("findings_json")); - var findings = JsonSerializer.Deserialize>(findingsJson, _findingsSerializerOptions) - ?? []; - var remIdCol = reader.GetOrdinal("remediation_work_item_id"); - WorkItemId? remId = reader.IsDBNull(remIdCol) ? null - : new WorkItemId(Guid.Parse(reader.GetString(remIdCol))); - result.Add(new ReleaseAuditIteration + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM release_audit_iterations WHERE release_id = $rid ORDER BY iteration ASC;"; + cmd.Parameters.AddWithValue("$rid", releaseId.ToString()); + using var reader = await cmd.ExecuteReaderAsync(ct); + var result = new List(); + while (await reader.ReadAsync(ct)) { - ReleaseId = ReleaseId.Parse(reader.GetString(reader.GetOrdinal("release_id"))), - Iteration = reader.GetInt32(reader.GetOrdinal("iteration")), - MaxIterations = reader.GetInt32(reader.GetOrdinal("max_iterations")), - TotalFindings = reader.GetInt32(reader.GetOrdinal("total_findings")), - BlockingFindings = reader.GetInt32(reader.GetOrdinal("blocking_findings")), - Findings = findings.Select(f => new AuditFinding(f.AuditorName, f.Severity, f.Title, f.Description, f.Location)).ToList(), - RemediationWorkItemId = remId, - CreatedAt = DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("created_at")), System.Globalization.CultureInfo.InvariantCulture), - }); + var findingsJson = reader.GetString(reader.GetOrdinal("findings_json")); + var findings = JsonSerializer.Deserialize>(findingsJson, _findingsSerializerOptions) + ?? []; + var remIdCol = reader.GetOrdinal("remediation_work_item_id"); + WorkItemId? remId = reader.IsDBNull(remIdCol) ? null + : new WorkItemId(Guid.Parse(reader.GetString(remIdCol))); + result.Add(new ReleaseAuditIteration + { + ReleaseId = ReleaseId.Parse(reader.GetString(reader.GetOrdinal("release_id"))), + Iteration = reader.GetInt32(reader.GetOrdinal("iteration")), + MaxIterations = reader.GetInt32(reader.GetOrdinal("max_iterations")), + TotalFindings = reader.GetInt32(reader.GetOrdinal("total_findings")), + BlockingFindings = reader.GetInt32(reader.GetOrdinal("blocking_findings")), + Findings = findings.Select(f => new AuditFinding(f.AuditorName, f.Severity, f.Title, f.Description, f.Location)).ToList(), + RemediationWorkItemId = remId, + CreatedAt = DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("created_at")), System.Globalization.CultureInfo.InvariantCulture), + }); + } + return result; + } + finally + { + _writeLock.Release(); } - return result; } public async Task SaveE2eReplayResultsAsync(ReleaseId releaseId, int iteration, IReadOnlyList results, CancellationToken ct = default) diff --git a/tests/CodeyBox.Tests/AuditTests.cs b/tests/CodeyBox.Tests/AuditTests.cs index d0a4cf2d..7c11acda 100644 --- a/tests/CodeyBox.Tests/AuditTests.cs +++ b/tests/CodeyBox.Tests/AuditTests.cs @@ -3,6 +3,7 @@ using CodeyBox.Audit.Presets; using CodeyBox.Audit.Shell; using CodeyBox.Core; +using CodeyBox.DotnetTestRunnerPlugin; using CodeyBox.Sandbox.Process; using Microsoft.Extensions.Logging.Abstractions; diff --git a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj index f649fd19..e9115ebe 100644 --- a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj +++ b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj @@ -52,6 +52,7 @@ +