Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions Aetheris.CLI/CliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json.Serialization;
using Aetheris.Kernel.Firmament;
using Aetheris.Kernel.Firmament.Assembly;
using Aetheris.Kernel.Firmament.FirmamentV2;
using Aetheris.Firmament.FrictionLab.CIRLab;

namespace Aetheris.CLI;
Expand Down Expand Up @@ -106,8 +107,9 @@ private sealed record CompareSideResult(
string? Error,
string? Classification = null,
int? RigidRootCount = null);
private const string TopLevelUsage = "Usage: aetheris <build|analyze|trace|canon|asm|experimental> <path> [options]";
private const string TopLevelUsage = "Usage: aetheris <build|validate|analyze|trace|canon|asm|experimental> <path> [options]";
private const string BuildUsage = "Usage: aetheris build <file.firmament> [--out <path>] [--json]";
private const string ValidateUsage = "Usage: aetheris validate <file.firmament|file.firmfixture> [--json]";
private const string AnalyzeUsage = "Usage: aetheris analyze <file.step> [--face <id>] [--edge <id>] [--vertex <id>] [--json]";
private const string AnalyzeMapUsage = "Usage: aetheris analyze map <file.step> (--plane <xy|xz|yz> --direction <+x|-x|+y|-y|+z|-z> | --views six --llm) --resolution <NxM> [--point <u,v>] [--rank-probes|--evidence-bundle] --json";
private const string AnalyzeSectionUsage = "Usage: aetheris analyze section <file.step> (--xy|--xz|--yz) --offset <value> --json";
Expand Down Expand Up @@ -161,6 +163,7 @@ public static int Run(string[] args, TextWriter stdout, TextWriter stderr)
return args[0] switch
{
"build" => RunBuild(args.Skip(1).ToArray(), stdout, stderr),
"validate" => RunValidate(args.Skip(1).ToArray(), stdout, stderr),
"analyze" => RunAnalyze(args.Skip(1).ToArray(), stdout, stderr),
"trace" => RunTrace(args.Skip(1).ToArray(), stdout, stderr),
"canon" => RunCanon(args.Skip(1).ToArray(), stdout, stderr),
Expand Down Expand Up @@ -269,6 +272,55 @@ private static int RunBuild(string[] args, TextWriter stdout, TextWriter stderr)
return 0;
}


private static int RunValidate(string[] args, TextWriter stdout, TextWriter stderr)
{
if (args.Length == 0)
{
stderr.WriteLine(ValidateUsage);
return 1;
}

if (IsHelpFlag(args[0]))
{
stdout.WriteLine(ValidateUsage);
stdout.WriteLine(" --json Emit Firmament V2 validation report JSON.");
return 0;
}

var sourcePath = args[0];
var json = false;
for (var i = 1; i < args.Length; i++)
{
switch (args[i])
{
case "--json":
json = true;
break;
case "-h":
case "--help":
stdout.WriteLine(ValidateUsage);
return 0;
default:
stderr.WriteLine($"Unknown validate option '{args[i]}'.");
stderr.WriteLine(ValidateUsage);
return 1;
}
}

if (!File.Exists(sourcePath))
{
stderr.WriteLine($"Validation input was not found: {sourcePath}");
return 1;
}

var parse = FirmamentV2Parser.Parse(File.ReadAllText(sourcePath), Path.GetDirectoryName(Path.GetFullPath(sourcePath)));
var report = FirmamentV2ValidationReportBuilder.Build(parse, sourcePath);
if (json) stdout.WriteLine(JsonSerializer.Serialize(new { firmamentV2Validation = report }, JsonOptions));
else stdout.WriteLine($"Firmament V2 validation: {report.Status} ({report.Summary.FatalDiagnosticCount} fatal, {report.Summary.WarningDiagnosticCount} warning)");
return report.Status == "invalid" ? 1 : 0;
}

private static int RunTrace(string[] args, TextWriter stdout, TextWriter stderr)
{
if (args.Length > 0 && IsHelpFlag(args[0]))
Expand Down Expand Up @@ -2137,7 +2189,7 @@ private static void WriteAnalyzeFailureText(TextWriter stderr, string stepPath,

private static int UnknownCommand(string command, TextWriter stderr)
{
stderr.WriteLine($"Unknown command '{command}'. Expected one of: build, analyze, trace, canon, asm, experimental.");
stderr.WriteLine($"Unknown command '{command}'. Expected one of: build, validate, analyze, trace, canon, asm, experimental.");
stderr.WriteLine("Run 'aetheris --help' for usage and examples.");
return 1;
}
Expand Down Expand Up @@ -2189,6 +2241,7 @@ private static void WriteTopLevelHelp(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Commands:");
stdout.WriteLine(" build Build a .firmament source file into STEP.");
stdout.WriteLine(" validate Validate Firmament V2 manufacturing intent and emit report JSON.");
stdout.WriteLine(" analyze Analyze STEP topology, geometry, map, and sections.");
stdout.WriteLine(" trace Trace built-in AIR lowering cases through route, BRepPlan, STEP smoke, and CIR mirror.");
stdout.WriteLine(" canon Import and re-export STEP/AP242 as canonical STEP.");
Expand All @@ -2201,6 +2254,7 @@ private static void WriteTopLevelHelp(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Examples:");
stdout.WriteLine(" aetheris build model.firmament --out model.step");
stdout.WriteLine(" aetheris validate fixtures/FirmamentV2/Language/valid/v2-phase1-validation-report.valid.firmfixture --json");
stdout.WriteLine(" aetheris analyze model.step");
stdout.WriteLine(" aetheris analyze model.step --json");
stdout.WriteLine(" aetheris trace --case top-face-loop-chamfer");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Aetheris.Kernel.Firmament.FirmamentV2;

namespace Aetheris.Kernel.Firmament.Tests;

public sealed class FirmamentV2ValidationReportTests
{
[Fact]
public void ValidationReport_ValidAuthoringFixtureSurfacesDeferredExport()
{
var report = Report("Language/valid/v2-phase1-validation-report.valid.firmfixture");

Assert.Equal("valid-with-deferred-export", report.Status);
Assert.Equal(7, report.Summary.LetCount);
Assert.Equal(4, report.Summary.TolerancedLetCount);
Assert.Equal(2, report.Summary.ConceptCount);
Assert.Equal(2, report.Summary.ValidConceptCount);
Assert.Equal(5, report.Summary.PmiRecordCount);
Assert.Equal(2, report.Summary.ExportSupportedPmiCount);
Assert.Equal(3, report.Summary.ExportDeferredPmiCount);
Assert.Equal(0, report.Summary.FatalDiagnosticCount);
Assert.Contains(report.Lets, l => l.Name == "MountingPattern.holeDiameter" && l.Tolerance is { Kind: "bilateral" });
Assert.Contains(report.Concepts, c => c.Kind == "feature" && c.Name == "mountHole" && c.Status == "valid" && c.Fields.Any(f => f.Name == "diameter" && f.HasTolerance));
Assert.Contains(report.Pmi, p => p.Name == "mountHoleDiameter" && p.ExportSupport == "supported" && p.Dimension?.Tolerance is not null);
Assert.Contains(report.Pmi, p => p.Name == "baseFlatness" && p.ExportSupport == "deferred");
Assert.Contains(report.Pmi, p => p.Name == "topParallel" && p.ExportSupport == "deferred" && p.DatumRefs.Contains("A"));
Assert.Contains(report.Diagnostics, d => d.Code == FirmamentV2Parser.ToleranceDroppedThroughArithmetic && d.Severity == "warning");
}

[Fact]
public void ValidationReport_ExportableSubsetHasNoDeferredRecords()
{
var report = Report("Language/valid/v2-phase1-validation-report-exportable.valid.firmfixture");

Assert.Equal("valid", report.Status);
Assert.Equal(2, report.Summary.PmiRecordCount);
Assert.Equal(2, report.Summary.ExportSupportedPmiCount);
Assert.Equal(0, report.Summary.ExportDeferredPmiCount);
Assert.DoesNotContain(report.Diagnostics, d => d.Severity == "fatal");
}

[Fact]
public void ValidationReport_MissingToleranceIsFatalPmiDiagnostic()
{
var report = Report("Language/invalid/v2-phase1-validation-report-missing-tolerance.invalid.firmfixture");

Assert.Equal("invalid", report.Status);
Assert.True(report.Summary.FatalDiagnosticCount > 0);
Assert.Contains(report.Diagnostics, d => d.Code == FirmamentV2Parser.PmiDimensionMissingTolerance && d.Severity == "fatal");
Assert.Contains(report.Pmi, p => p.Name == "d1" && p.Status == "invalid" && p.Diagnostics.Any(d => d.Code == FirmamentV2Parser.PmiDimensionMissingTolerance));
}

[Fact]
public void ValidationReport_ForgeMissingFieldIsFatalConceptDiagnostic()
{
var report = Report("Language/invalid/v2-phase1-validation-report-forge-missing-field.invalid.firmfixture");

Assert.Equal("invalid", report.Status);
Assert.Contains(report.Diagnostics, d => d.Code == FirmamentV2Parser.ConceptMissingRequiredField && d.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public void ValidationReport_UnknownDatumIsFatalPmiDiagnostic()
{
var report = Report("Language/invalid/v2-phase1-validation-report-unknown-datum.invalid.firmfixture");

Assert.Equal("invalid", report.Status);
Assert.Contains(report.Diagnostics, d => d.Code == FirmamentV2Parser.PmiUnknownDatum && d.Severity == "fatal");
}

private static FirmamentV2ValidationReport Report(string relative)
{
var path = Path.Combine(FindRepoRoot(), "fixtures/FirmamentV2", relative);
var parse = FirmamentV2Parser.Parse(File.ReadAllText(path), Path.GetDirectoryName(path));
return FirmamentV2ValidationReportBuilder.Build(parse, path);
}

private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "Aetheris.slnx"))) dir = Directory.GetParent(dir)?.FullName;
return dir ?? throw new DirectoryNotFoundException("Could not find Aetheris.slnx");
}
}
Loading
Loading