Files
natsnet/tools/NatsNet.PortTracker/Commands/AuditCommand.cs

240 lines
9.2 KiB
C#

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<string> dbOption)
{
var sourceOpt = new Option<string?>("--source")
{
Description = "Path to the .NET source directory (defaults based on --type)"
};
var outputOpt = new Option<string?>("--output")
{
Description = "CSV report output path (defaults based on --type)"
};
var moduleOpt = new Option<int?>("--module")
{
Description = "Restrict to a specific module ID"
};
var executeOpt = new Option<bool>("--execute")
{
Description = "Apply DB updates (default: dry-run preview)",
DefaultValueFactory = _ => false
};
var typeOpt = new Option<string>("--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<string>();
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;
}
}