using System.CommandLine; using System.Text; using NatsNet.PortTracker.Audit; using NatsNet.PortTracker.Data; namespace NatsNet.PortTracker.Commands; public static class AuditCommand { private record AuditTarget(string Table, string Label, string DefaultSource, string DefaultOutput); private static readonly AuditTarget FeaturesTarget = new( "features", "features", Path.Combine(Directory.GetCurrentDirectory(), "dotnet", "src", "ZB.MOM.NatsNet.Server"), Path.Combine(Directory.GetCurrentDirectory(), "reports", "audit-results.csv")); private static readonly AuditTarget TestsTarget = new( "unit_tests", "unit tests", Path.Combine(Directory.GetCurrentDirectory(), "dotnet", "tests", "ZB.MOM.NatsNet.Server.Tests"), Path.Combine(Directory.GetCurrentDirectory(), "reports", "audit-results-tests.csv")); public static Command Create(Option dbOption) { var sourceOpt = new Option("--source") { Description = "Path to the .NET source directory (defaults based on --type)" }; var outputOpt = new Option("--output") { Description = "CSV report output path (defaults based on --type)" }; var moduleOpt = new Option("--module") { Description = "Restrict to a specific module ID" }; var executeOpt = new Option("--execute") { Description = "Apply DB updates (default: dry-run preview)", DefaultValueFactory = _ => false }; var typeOpt = new Option("--type") { Description = "What to audit: features, tests, or all", DefaultValueFactory = _ => "features" }; var cmd = new Command("audit", "Classify unknown features/tests by inspecting .NET source code"); cmd.Add(sourceOpt); cmd.Add(outputOpt); cmd.Add(moduleOpt); cmd.Add(executeOpt); cmd.Add(typeOpt); cmd.SetAction(parseResult => { var dbPath = parseResult.GetValue(dbOption)!; var sourceOverride = parseResult.GetValue(sourceOpt); var outputOverride = parseResult.GetValue(outputOpt); var moduleId = parseResult.GetValue(moduleOpt); var execute = parseResult.GetValue(executeOpt); var type = parseResult.GetValue(typeOpt)!; AuditTarget[] targets = type switch { "features" => [FeaturesTarget], "tests" => [TestsTarget], "all" => [FeaturesTarget, TestsTarget], _ => throw new ArgumentException($"Unknown audit type: {type}. Use features, tests, or all.") }; foreach (var target in targets) { var sourcePath = sourceOverride ?? target.DefaultSource; var outputPath = outputOverride ?? target.DefaultOutput; RunAudit(dbPath, sourcePath, outputPath, moduleId, execute, target); } }); return cmd; } private static void RunAudit(string dbPath, string sourcePath, string outputPath, int? moduleId, bool execute, AuditTarget target) { if (!Directory.Exists(sourcePath)) { Console.WriteLine($"Error: source directory not found: {sourcePath}"); return; } Console.WriteLine($"Parsing .NET source files in {sourcePath}..."); var indexer = new SourceIndexer(); indexer.IndexDirectory(sourcePath); Console.WriteLine($"Indexed {indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods/properties."); using var db = new Database(dbPath); var sql = $"SELECT id, dotnet_class, dotnet_method, go_file, go_method FROM {target.Table} WHERE status = 'unknown'"; var parameters = new List<(string, object?)>(); if (moduleId is not null) { sql += " AND module_id = @module"; parameters.Add(("@module", moduleId)); } sql += " ORDER BY id"; var rows = db.Query(sql, parameters.ToArray()); if (rows.Count == 0) { Console.WriteLine($"No unknown {target.Label} found."); return; } Console.WriteLine($"Found {rows.Count} unknown {target.Label} to classify.\n"); var classifier = new FeatureClassifier(indexer); var results = new List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)>(); foreach (var row in rows) { var feature = new FeatureClassifier.FeatureRecord( Id: Convert.ToInt64(row["id"]), DotnetClass: row["dotnet_class"]?.ToString() ?? "", DotnetMethod: row["dotnet_method"]?.ToString() ?? "", GoFile: row["go_file"]?.ToString() ?? "", GoMethod: row["go_method"]?.ToString() ?? ""); var result = classifier.Classify(feature); results.Add((feature, result)); } WriteCsvReport(outputPath, results); var grouped = results.GroupBy(r => r.Result.Status) .ToDictionary(g => g.Key, g => g.Count()); var label = char.ToUpper(target.Label[0]) + target.Label[1..]; Console.WriteLine($"{label} Status Audit Results"); Console.WriteLine(new string('=', $"{label} Status Audit Results".Length)); Console.WriteLine($"Source: {sourcePath} ({indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods indexed)"); Console.WriteLine($"{label} audited: {results.Count}"); Console.WriteLine(); Console.WriteLine($" verified: {grouped.GetValueOrDefault("verified", 0)}"); Console.WriteLine($" stub: {grouped.GetValueOrDefault("stub", 0)}"); Console.WriteLine($" n_a: {grouped.GetValueOrDefault("n_a", 0)}"); Console.WriteLine($" deferred: {grouped.GetValueOrDefault("deferred", 0)}"); Console.WriteLine(); if (!execute) { Console.WriteLine("Dry-run mode. Add --execute to apply changes."); Console.WriteLine($"Report: {outputPath}"); return; } ApplyUpdates(db, results, target); Console.WriteLine($"Report: {outputPath}"); } private static void WriteCsvReport( string outputPath, List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results) { var dir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var sb = new StringBuilder(); sb.AppendLine("id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason"); foreach (var (feature, result) in results) { sb.AppendLine($"{feature.Id},{CsvEscape(feature.DotnetClass)},{CsvEscape(feature.DotnetMethod)},{CsvEscape(feature.GoFile)},{CsvEscape(feature.GoMethod)},unknown,{result.Status},{CsvEscape(result.Reason)}"); } File.WriteAllText(outputPath, sb.ToString()); } private static void ApplyUpdates( Database db, List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results, AuditTarget target) { var groups = results .GroupBy(r => (r.Result.Status, Notes: r.Result.Status == "n_a" ? r.Result.Reason : (string?)null)) .ToList(); var totalUpdated = 0; using var transaction = db.Connection.BeginTransaction(); try { foreach (var group in groups) { var ids = group.Select(r => r.Feature.Id).ToList(); var status = group.Key.Status; var notes = group.Key.Notes; var placeholders = new List(); using var cmd = db.CreateCommand(""); for (var i = 0; i < ids.Count; i++) { placeholders.Add($"@id{i}"); cmd.Parameters.AddWithValue($"@id{i}", ids[i]); } cmd.Parameters.AddWithValue("@status", status); if (notes is not null) { cmd.CommandText = $"UPDATE {target.Table} SET status = @status, notes = @notes WHERE id IN ({string.Join(", ", placeholders)})"; cmd.Parameters.AddWithValue("@notes", notes); } else { cmd.CommandText = $"UPDATE {target.Table} SET status = @status WHERE id IN ({string.Join(", ", placeholders)})"; } cmd.Transaction = transaction; var affected = cmd.ExecuteNonQuery(); totalUpdated += affected; } transaction.Commit(); Console.WriteLine($"Updated {totalUpdated} {target.Label}."); } catch { transaction.Rollback(); Console.WriteLine("Error: transaction rolled back."); throw; } } private static string CsvEscape(string value) { if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) return $"\"{value.Replace("\"", "\"\"")}\""; return value; } }