save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -6,7 +6,9 @@
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
@@ -43,17 +45,20 @@ public sealed class AocCliCommandModule : ICliCommandModule
{
var aoc = new Command("aoc", "Append-Only Contract verification commands.");
var verify = BuildVerifyCommand(verboseOption, cancellationToken);
var verify = BuildVerifyCommand(services, verboseOption, cancellationToken);
aoc.Add(verify);
return aoc;
}
private static Command BuildVerifyCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
private static Command BuildVerifyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sinceOption = new Option<string>("--since", "-s")
{
Description = "Git commit SHA or ISO timestamp to verify from",
Description = "ISO-8601 timestamp to verify from (UTC recommended)",
Required = true
};
@@ -96,94 +101,95 @@ public sealed class AocCliCommandModule : ICliCommandModule
verify.SetAction(async (parseResult, ct) =>
{
var since = parseResult.GetValue(sinceOption)!;
var postgres = parseResult.GetValue(postgresOption)!;
var output = parseResult.GetValue(outputOption);
var ndjson = parseResult.GetValue(ndjsonOption);
var tenant = parseResult.GetValue(tenantOption);
var dryRun = parseResult.GetValue(dryRunOption);
var verbose = parseResult.GetValue(verboseOption);
var options = new AocVerifyOptions
var rawOptions = new AocVerifyRawOptions
{
Since = since,
PostgresConnectionString = postgres,
OutputPath = output,
NdjsonPath = ndjson,
Tenant = tenant,
DryRun = dryRun,
Verbose = verbose
Since = parseResult.GetValue(sinceOption)!,
PostgresConnectionString = parseResult.GetValue(postgresOption)!,
OutputPath = parseResult.GetValue(outputOption),
NdjsonPath = parseResult.GetValue(ndjsonOption),
Tenant = parseResult.GetValue(tenantOption),
DryRun = parseResult.GetValue(dryRunOption),
Verbose = parseResult.GetValue(verboseOption)
};
return await ExecuteVerifyAsync(options, ct);
if (!AocVerifyOptionsParser.TryParse(rawOptions, out var options, out var errorMessage))
{
await Console.Error.WriteLineAsync(errorMessage);
return 1;
}
var service = ResolveVerificationService(services);
return await ExecuteVerifyAsync(options, service, Console.Out, Console.Error, ct);
});
return verify;
}
private static async Task<int> ExecuteVerifyAsync(AocVerifyOptions options, CancellationToken cancellationToken)
private static async Task<int> ExecuteVerifyAsync(
AocVerifyOptions options,
IAocVerificationService verificationService,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken)
{
if (options.Verbose)
{
Console.WriteLine("AOC Verify starting...");
Console.WriteLine($" Since: {options.Since}");
Console.WriteLine($" Tenant: {options.Tenant ?? "(all)"}");
Console.WriteLine($" Dry run: {options.DryRun}");
await output.WriteLineAsync("AOC Verify starting...");
await output.WriteLineAsync($" Since: {options.Since:O}");
await output.WriteLineAsync($" Tenant: {options.Tenant ?? "(all)"}");
await output.WriteLineAsync($" Dry run: {options.DryRun}");
}
if (options.DryRun)
{
Console.WriteLine("Dry run mode - configuration validated successfully");
await output.WriteLineAsync("Dry run mode - configuration validated successfully");
return 0;
}
try
{
var service = new AocVerificationService();
var result = await service.VerifyAsync(options, cancellationToken);
var result = await verificationService.VerifyAsync(options, cancellationToken);
// Write JSON output if requested
if (!string.IsNullOrEmpty(options.OutputPath))
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
EnsureOutputDirectory(options.OutputPath);
var json = JsonSerializer.Serialize(result, JsonIndentedOptions);
await File.WriteAllTextAsync(options.OutputPath, json, cancellationToken);
if (options.Verbose)
{
Console.WriteLine($"JSON report written to: {options.OutputPath}");
await output.WriteLineAsync($"JSON report written to: {options.OutputPath}");
}
}
// Write NDJSON output if requested
if (!string.IsNullOrEmpty(options.NdjsonPath))
{
var ndjsonLines = result.Violations.Select(v =>
JsonSerializer.Serialize(v, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
await File.WriteAllLinesAsync(options.NdjsonPath, ndjsonLines, cancellationToken);
EnsureOutputDirectory(options.NdjsonPath);
await WriteNdjsonAsync(options.NdjsonPath, result.Violations, cancellationToken);
if (options.Verbose)
{
Console.WriteLine($"NDJSON report written to: {options.NdjsonPath}");
await output.WriteLineAsync($"NDJSON report written to: {options.NdjsonPath}");
}
}
// Output summary
Console.WriteLine("AOC Verification Complete");
Console.WriteLine($" Documents scanned: {result.DocumentsScanned}");
Console.WriteLine($" Violations found: {result.ViolationCount}");
Console.WriteLine($" Duration: {result.DurationMs}ms");
await output.WriteLineAsync("AOC Verification Complete");
await output.WriteLineAsync($" Documents scanned: {result.DocumentsScanned}");
await output.WriteLineAsync($" Violations found: {result.ViolationCount}");
await output.WriteLineAsync($" Duration: {result.DurationMs}ms");
if (result.ViolationCount > 0)
{
Console.WriteLine();
Console.WriteLine("Violations by type:");
foreach (var group in result.Violations.GroupBy(v => v.Code))
await output.WriteLineAsync();
await output.WriteLineAsync("Violations by type:");
foreach (var group in result.Violations
.GroupBy(v => v.Code)
.OrderBy(g => g.Key, StringComparer.Ordinal))
{
Console.WriteLine($" {group.Key}: {group.Count()}");
await output.WriteLineAsync($" {group.Key}: {group.Count()}");
}
}
@@ -191,139 +197,58 @@ public sealed class AocCliCommandModule : ICliCommandModule
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error during verification: {ex.Message}");
await error.WriteLineAsync($"Error during verification: {ex.Message}");
if (options.Verbose)
{
Console.Error.WriteLine(ex.StackTrace);
await error.WriteLineAsync(ex.ToString());
}
return 1;
}
}
}
private static IAocVerificationService ResolveVerificationService(IServiceProvider services)
{
var resolvedService = services.GetService<IAocVerificationService>();
if (resolvedService is not null)
{
return resolvedService;
}
/// <summary>
/// Options for AOC verify command.
/// </summary>
public sealed class AocVerifyOptions
{
public required string Since { get; init; }
public required string PostgresConnectionString { get; init; }
public string? OutputPath { get; init; }
public string? NdjsonPath { get; init; }
public string? Tenant { get; init; }
public bool DryRun { get; init; }
public bool Verbose { get; init; }
}
var connectionFactory = services.GetService<IAocConnectionFactory>() ?? new NpgsqlConnectionFactory();
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
return new AocVerificationService(connectionFactory, timeProvider);
}
/// <summary>
/// Service for AOC verification operations.
/// </summary>
public sealed class AocVerificationService
{
public async Task<AocVerificationResult> VerifyAsync(
AocVerifyOptions options,
private static void EnsureOutputDirectory(string path)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
private static async Task WriteNdjsonAsync(
string path,
IReadOnlyList<AocViolation> violations,
CancellationToken cancellationToken)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var violations = new List<AocViolation>();
var documentsScanned = 0;
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
try
foreach (var violation in violations)
{
await using var connection = new Npgsql.NpgsqlConnection(options.PostgresConnectionString);
await connection.OpenAsync(cancellationToken);
// Query for documents to verify
var query = BuildVerificationQuery(options);
await using var cmd = new Npgsql.NpgsqlCommand(query, connection);
if (!string.IsNullOrEmpty(options.Tenant))
{
cmd.Parameters.AddWithValue("tenant", options.Tenant);
}
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
documentsScanned++;
// Check for AOC violations
var documentId = reader.GetString(0);
var hash = reader.IsDBNull(1) ? null : reader.GetString(1);
var previousHash = reader.IsDBNull(2) ? null : reader.GetString(2);
var createdAt = reader.GetDateTime(3);
// Verify hash chain integrity
if (hash != null && previousHash != null)
{
// Placeholder: actual verification logic would check hash chain
// For now, just record that we verified
}
}
var line = JsonSerializer.Serialize(violation, JsonOptions);
await writer.WriteLineAsync(line.AsMemory(), cancellationToken);
}
catch (Exception ex)
{
violations.Add(new AocViolation
{
Code = "AOC-001",
Message = $"Database verification failed: {ex.Message}",
DocumentId = null,
Severity = "error"
});
}
stopwatch.Stop();
return new AocVerificationResult
{
DocumentsScanned = documentsScanned,
ViolationCount = violations.Count,
Violations = violations,
DurationMs = stopwatch.ElapsedMilliseconds,
VerifiedAt = DateTimeOffset.UtcNow
};
}
private static string BuildVerificationQuery(AocVerifyOptions options)
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
// Placeholder query - actual implementation would query AOC tables
var baseQuery = """
SELECT id, hash, previous_hash, created_at
FROM aoc_documents
WHERE created_at >= @since
""";
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
if (!string.IsNullOrEmpty(options.Tenant))
{
baseQuery += " AND tenant_id = @tenant";
}
baseQuery += " ORDER BY created_at ASC";
return baseQuery;
}
}
/// <summary>
/// Result of AOC verification.
/// </summary>
public sealed class AocVerificationResult
{
public int DocumentsScanned { get; init; }
public int ViolationCount { get; init; }
public IReadOnlyList<AocViolation> Violations { get; init; } = [];
public long DurationMs { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
}
/// <summary>
/// An AOC violation record.
/// </summary>
public sealed class AocViolation
{
public required string Code { get; init; }
public required string Message { get; init; }
public string? DocumentId { get; init; }
public required string Severity { get; init; }
private static readonly JsonSerializerOptions JsonIndentedOptions = new(JsonOptions)
{
WriteIndented = true
};
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Cli.Plugins.Aoc;
/// <summary>
/// Result of AOC verification.
/// </summary>
public sealed class AocVerificationResult
{
public int DocumentsScanned { get; init; }
public int ViolationCount { get; init; }
public IReadOnlyList<AocViolation> Violations { get; init; } = [];
public long DurationMs { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
}
/// <summary>
/// An AOC violation record.
/// </summary>
public sealed class AocViolation
{
public required string Code { get; init; }
public required string Message { get; init; }
public string? DocumentId { get; init; }
public required string Severity { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.Text;
using Npgsql;
namespace StellaOps.Cli.Plugins.Aoc;
public interface IAocVerificationService
{
Task<AocVerificationResult> VerifyAsync(AocVerifyOptions options, CancellationToken cancellationToken);
}
public interface IAocConnectionFactory
{
ValueTask<NpgsqlConnection> OpenConnectionAsync(string connectionString, CancellationToken cancellationToken);
}
public sealed class NpgsqlConnectionFactory : IAocConnectionFactory
{
public async ValueTask<NpgsqlConnection> OpenConnectionAsync(string connectionString, CancellationToken cancellationToken)
{
var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
}
public sealed class AocVerificationService : IAocVerificationService
{
private readonly IAocConnectionFactory _connectionFactory;
private readonly TimeProvider _timeProvider;
public AocVerificationService(IAocConnectionFactory connectionFactory, TimeProvider timeProvider)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<AocVerificationResult> VerifyAsync(
AocVerifyOptions options,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var violations = new List<AocViolation>();
var documentsScanned = 0;
var query = AocVerificationQueryBuilder.Build(options);
await using var connection = await _connectionFactory.OpenConnectionAsync(
options.PostgresConnectionString,
cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(query.Sql, connection);
query.BindParameters(cmd);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
documentsScanned++;
_ = reader.GetString(0);
var hash = reader.IsDBNull(1) ? null : reader.GetString(1);
var previousHash = reader.IsDBNull(2) ? null : reader.GetString(2);
_ = reader.GetDateTime(3);
if (hash is null || previousHash is null)
{
continue;
}
// TODO: implement hash chain verification and emit violations.
}
stopwatch.Stop();
return new AocVerificationResult
{
DocumentsScanned = documentsScanned,
ViolationCount = violations.Count,
Violations = violations,
DurationMs = stopwatch.ElapsedMilliseconds,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
}
public readonly record struct AocVerificationQuery(string Sql, Action<NpgsqlCommand> BindParameters);
public static class AocVerificationQueryBuilder
{
public static AocVerificationQuery Build(AocVerifyOptions options)
{
var builder = new StringBuilder();
builder.AppendLine("SELECT id, hash, previous_hash, created_at");
builder.AppendLine("FROM aoc_documents");
builder.AppendLine("WHERE created_at >= @since");
if (!string.IsNullOrWhiteSpace(options.Tenant))
{
builder.AppendLine("AND tenant_id = @tenant");
}
builder.AppendLine("ORDER BY created_at ASC");
return new AocVerificationQuery(builder.ToString(), command =>
{
command.Parameters.AddWithValue("since", options.Since.UtcDateTime);
if (!string.IsNullOrWhiteSpace(options.Tenant))
{
command.Parameters.AddWithValue("tenant", options.Tenant);
}
});
}
}

View File

@@ -0,0 +1,135 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace StellaOps.Cli.Plugins.Aoc;
public sealed class AocVerifyRawOptions
{
public required string Since { get; init; }
public required string PostgresConnectionString { get; init; }
public string? OutputPath { get; init; }
public string? NdjsonPath { get; init; }
public string? Tenant { get; init; }
public bool DryRun { get; init; }
public bool Verbose { get; init; }
}
public sealed class AocVerifyOptions
{
public required DateTimeOffset Since { get; init; }
public required string PostgresConnectionString { get; init; }
public string? OutputPath { get; init; }
public string? NdjsonPath { get; init; }
public string? Tenant { get; init; }
public bool DryRun { get; init; }
public bool Verbose { get; init; }
}
public static class AocVerifyOptionsParser
{
private static readonly Regex CommitShaRegex = new("^[a-fA-F0-9]{7,40}$", RegexOptions.Compiled);
public static bool TryParse(
AocVerifyRawOptions raw,
out AocVerifyOptions options,
out string errorMessage)
{
options = default!;
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(raw.PostgresConnectionString))
{
errorMessage = "PostgreSQL connection string is required.";
return false;
}
if (!TryParseSince(raw.Since, out var since, out errorMessage))
{
return false;
}
if (!ValidateOutputPaths(raw.OutputPath, raw.NdjsonPath, out errorMessage))
{
return false;
}
options = new AocVerifyOptions
{
Since = since,
PostgresConnectionString = raw.PostgresConnectionString,
OutputPath = raw.OutputPath,
NdjsonPath = raw.NdjsonPath,
Tenant = raw.Tenant,
DryRun = raw.DryRun,
Verbose = raw.Verbose
};
return true;
}
private static bool TryParseSince(
string value,
out DateTimeOffset since,
out string errorMessage)
{
errorMessage = string.Empty;
since = default;
if (DateTimeOffset.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces,
out var parsed))
{
since = parsed.ToUniversalTime();
return true;
}
if (CommitShaRegex.IsMatch(value))
{
errorMessage = "Commit-based --since values are not supported yet; provide an ISO-8601 timestamp.";
return false;
}
errorMessage = "Invalid --since value; expected an ISO-8601 timestamp.";
return false;
}
private static bool ValidateOutputPaths(
string? outputPath,
string? ndjsonPath,
out string errorMessage)
{
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(outputPath) && string.IsNullOrWhiteSpace(ndjsonPath))
{
return true;
}
if (!string.IsNullOrWhiteSpace(outputPath) && Path.EndsInDirectorySeparator(outputPath))
{
errorMessage = "--output must be a file path, not a directory.";
return false;
}
if (!string.IsNullOrWhiteSpace(ndjsonPath) && Path.EndsInDirectorySeparator(ndjsonPath))
{
errorMessage = "--ndjson must be a file path, not a directory.";
return false;
}
if (!string.IsNullOrWhiteSpace(outputPath) && !string.IsNullOrWhiteSpace(ndjsonPath))
{
var outputFullPath = Path.GetFullPath(outputPath);
var ndjsonFullPath = Path.GetFullPath(ndjsonPath);
if (string.Equals(outputFullPath, ndjsonFullPath, StringComparison.OrdinalIgnoreCase))
{
errorMessage = "--output and --ndjson must point to different files.";
return false;
}
}
return true;
}
}

View File

@@ -10,7 +10,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Aoc\'))</PluginOutputDirectory>
</PropertyGroup>
@@ -25,9 +25,13 @@
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<MakeDir Directories="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(PluginOutputDirectory)"
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<ItemGroup>
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
</ItemGroup>
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0138-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Aoc. |
| AUDIT-0138-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Aoc. |
| AUDIT-0138-A | TODO | Pending approval for changes. |
| AUDIT-0138-A | DONE | Applied option validation, query binding, deterministic output, and tests. |