diff --git a/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs b/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs
new file mode 100644
index 0000000..e59bb3f
--- /dev/null
+++ b/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs
@@ -0,0 +1,193 @@
+using TALXIS.Platform.Metadata.Serialization.Xml;
+using TALXIS.Platform.Metadata.Components;
+
+namespace TALXIS.Platform.Metadata.Validation;
+
+///
+/// Validates a single solution root with every rule that needs no cross-solution knowledge:
+/// XSD and JSON schema checks, duplicate GUID detection, model load, flow and solution manifest
+/// rules. Relationship rules are workspace-scoped (a relationship and the entity it references
+/// may ship in different solutions), so they live in .
+/// composes this validator over each solution it discovers;
+/// build pipelines call it directly on one solution source folder.
+///
+public sealed class SolutionValidator
+{
+ private readonly SchemaValidator _schemaValidator;
+ private readonly JsonValidator _jsonValidator;
+
+ ///
+ /// Creates a validator with freshly compiled embedded schema sets.
+ ///
+ public SolutionValidator() : this(new SchemaValidator(), new JsonValidator()) { }
+
+ internal SolutionValidator(SchemaValidator schemaValidator, JsonValidator jsonValidator)
+ {
+ _schemaValidator = schemaValidator;
+ _jsonValidator = jsonValidator;
+ }
+
+ ///
+ /// Runs all single-solution checks on a solution root directory.
+ ///
+ /// Directory containing the unpacked solution (the folder with Other/Solution.xml).
+ public WorkspaceValidationReport Validate(string solutionRootPath)
+ {
+ var results = new List();
+
+ if (!Directory.Exists(solutionRootPath))
+ {
+ results.Add(new ValidationResult(ValidationSeverity.Error,
+ $"Directory not found: {solutionRootPath}", null, null, null));
+ return WorkspaceValidator.BuildReport(results, null);
+ }
+
+ if (!File.Exists(Path.Combine(solutionRootPath, "Other", "Solution.xml")))
+ {
+ results.Add(new ValidationResult(ValidationSeverity.Warning,
+ $"No Other/Solution.xml found under '{solutionRootPath}'. If this directory holds multiple solutions, validate it with {nameof(WorkspaceValidator)} instead.",
+ null, null, null) { Code = ValidationDiagnostics.SolutionManifestFileAbsent });
+ }
+
+ CollectFileFindings(solutionRootPath, results);
+
+ var workspace = TryLoad(solutionRootPath, results);
+ if (workspace != null)
+ CollectModelFindingsSafe(workspace, results, solutionRootPath);
+
+ return WorkspaceValidator.BuildReport(results, workspace);
+ }
+
+ ///
+ /// Runs all single-solution checks against an already-loaded solution workspace.
+ ///
+ public WorkspaceValidationReport Validate(Workspace solution, string solutionRootPath)
+ {
+ if (solution == null) throw new ArgumentNullException(nameof(solution));
+
+ var results = new List();
+ if (Directory.Exists(solutionRootPath))
+ CollectFileFindings(solutionRootPath, results);
+
+ CollectModelFindingsSafe(solution, results, solutionRootPath);
+ return WorkspaceValidator.BuildReport(results, solution);
+ }
+
+ ///
+ /// Model checks with the same safety net the load has: a crashing rule becomes a finding,
+ /// not an exception escaping to the consumer.
+ ///
+ internal void CollectModelFindingsSafe(Workspace workspace, List results, string solutionRootPath)
+ {
+ try
+ {
+ CollectModelFindings(workspace, results);
+ }
+ catch (Exception ex)
+ {
+ results.Add(new ValidationResult(
+ ValidationSeverity.Error,
+ $"Failed to load workspace into model: {ex.Message}",
+ solutionRootPath, null, null) { Stage = ValidationStage.ModelLoad });
+ }
+ }
+
+ ///
+ /// File-level checks for one solution root: XSD schemas, JSON schemas, duplicate GUIDs.
+ ///
+ internal void CollectFileFindings(string solutionRootPath, List results)
+ {
+ CollectSchemaAndJsonFindings(solutionRootPath, results);
+ results.AddRange(WithStage(new GuidValidator().ValidateDirectory(solutionRootPath), ValidationStage.DuplicateGuid));
+ }
+
+ ///
+ /// XSD and JSON checks only, optionally restricted to files matching .
+ ///
+ internal void CollectSchemaAndJsonFindings(string directory, List results, Func? includeFile = null)
+ {
+ ValidateFiles(directory, "*.xml", ValidationStage.Schema,
+ file => WorkspaceFiles.IsWebResourcePayload(file) ? Array.Empty() : _schemaValidator.ValidateFile(file),
+ results, includeFile);
+
+ ValidateFiles(directory, "*.json", ValidationStage.Json, _jsonValidator.ValidateFile, results, includeFile);
+ }
+
+ ///
+ /// Model-level checks for one loaded solution: load errors, flow diagnostics,
+ /// solution manifest rules.
+ ///
+ private void CollectModelFindings(Workspace workspace, List results)
+ {
+ foreach (var loadError in workspace.LoadErrors)
+ {
+ results.Add(new ValidationResult(
+ ValidationSeverity.Error,
+ $"Load error: {loadError.Message}",
+ loadError.FilePath,
+ loadError.Line,
+ loadError.Column) { Stage = ValidationStage.ModelLoad });
+ }
+
+ foreach (var diagnostic in workspace.FlowDefinitions.SelectMany(f => f.Diagnostics))
+ {
+ results.Add(new ValidationResult(
+ MapFlowSeverity(diagnostic.Severity),
+ $"Flow {diagnostic.Code}: {diagnostic.Message}",
+ diagnostic.FilePath,
+ diagnostic.Line,
+ diagnostic.Column) { Stage = ValidationStage.Flow });
+ }
+
+ results.AddRange(new SolutionManifestValidator().Validate(workspace));
+ }
+
+ internal static Workspace? TryLoad(string solutionRootPath, List results)
+ {
+ try
+ {
+ return new XmlWorkspaceReader().Load(solutionRootPath);
+ }
+ catch (Exception ex)
+ {
+ results.Add(new ValidationResult(
+ ValidationSeverity.Error,
+ $"Failed to load workspace into model: {ex.Message}",
+ solutionRootPath, null, null) { Stage = ValidationStage.ModelLoad });
+ return null;
+ }
+ }
+
+ private static void ValidateFiles(
+ string directory,
+ string pattern,
+ ValidationStage stage,
+ Func> validate,
+ List results,
+ Func? includeFile)
+ {
+ foreach (var file in WorkspaceFiles.Enumerate(directory, pattern))
+ {
+ if (includeFile != null && !includeFile(file)) continue;
+
+ try
+ {
+ results.AddRange(WithStage(validate(file), stage));
+ }
+ catch (IOException ex)
+ {
+ results.Add(new ValidationResult(ValidationSeverity.Warning,
+ $"Cannot read file: {ex.Message}", file, null, null) { Stage = stage });
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ results.Add(new ValidationResult(ValidationSeverity.Warning,
+ $"Access denied: {ex.Message}", file, null, null) { Stage = stage });
+ }
+ }
+ }
+
+ internal static IEnumerable WithStage(IEnumerable results, ValidationStage stage) => results.Select(r => r with { Stage = stage });
+
+ private static ValidationSeverity MapFlowSeverity(FlowDiagnosticSeverity severity) => severity == FlowDiagnosticSeverity.Error ? ValidationSeverity.Error : ValidationSeverity.Warning;
+}
diff --git a/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs b/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs
index 86a1f2b..be4b554 100644
--- a/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs
+++ b/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs
@@ -21,4 +21,7 @@ public static class ValidationDiagnostics
/// A solution manifest declares more than one root component of the same type with the same identity (schema name or id).
public const string DuplicateRootComponent = "TXM004";
+
+ /// The directory passed to solution validation has no Other/Solution.xml manifest.
+ public const string SolutionManifestFileAbsent = "TXM005";
}
diff --git a/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs b/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs
new file mode 100644
index 0000000..da2c27f
--- /dev/null
+++ b/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs
@@ -0,0 +1,33 @@
+namespace TALXIS.Platform.Metadata.Validation;
+
+///
+/// Shared file-enumeration rules for validators that scan a workspace tree.
+///
+internal static class WorkspaceFiles
+{
+ internal static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "bin", "obj", ".vs", ".git", ".github", "node_modules", "packages", "TestResults"
+ };
+
+ internal static IEnumerable Enumerate(string directory, string pattern)
+ {
+ var fullDir = Path.GetFullPath(directory);
+ foreach (var file in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories))
+ {
+ var fullFile = Path.GetFullPath(file);
+ var relativePath = fullFile.Substring(fullDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (parts.Any(p => IgnoredDirectories.Contains(p)))
+ continue;
+ yield return file;
+ }
+ }
+
+ internal static bool IsWebResourcePayload(string filePath)
+ {
+ if (filePath.EndsWith(".data.xml", StringComparison.OrdinalIgnoreCase)) return false;
+ var normalized = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
+ return normalized.IndexOf($"{Path.DirectorySeparatorChar}WebResources{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+}
diff --git a/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs b/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs
index bca8fec..56c5e8c 100644
--- a/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs
+++ b/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs
@@ -1,40 +1,18 @@
using TALXIS.Platform.Metadata.Serialization.Xml;
-using TALXIS.Platform.Metadata.Components;
namespace TALXIS.Platform.Metadata.Validation;
///
-/// Unified validation entry point. Runs all registered validators
-/// and optionally loads the workspace into the typed model.
-/// Consumers call this single method instead of wiring validators individually.
+/// Unified validation entry point for a whole workspace. Discovers every solution root,
+/// runs on each, then adds the checks that only make sense
+/// across solutions: cross-solution duplicate GUIDs, files outside any solution root, and
+/// the combined workspace model.
///
public sealed class WorkspaceValidator
{
- private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase)
- {
- "bin", "obj", ".vs", ".git", ".github", "node_modules", "packages", "TestResults"
- };
-
-
- private static IEnumerable EnumerateWorkspaceFiles(string directory, string pattern)
- {
- var fullDir = Path.GetFullPath(directory);
- foreach (var file in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories))
- {
- // Check if any parent directory is in the ignore list
- var fullFile = Path.GetFullPath(file);
- var relativePath = fullFile.Substring(fullDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
- var parts = relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar });
- if (parts.Any(p => IgnoredDirectories.Contains(p)))
- continue;
- yield return file;
- }
- }
-
///
/// Runs all validation checks on a solution workspace directory:
- /// XSD schema validation, JSON schema validation, duplicate GUID detection,
- /// and model loading (reports parse errors and component counts).
+ /// per-solution checks (XSD, JSON, GUIDs, model rules) plus cross-solution checks.
///
/// Path to the unpacked SolutionPackager workspace.
/// A validation report containing all findings and the loaded workspace when loading succeeded.
@@ -50,76 +28,122 @@ public WorkspaceValidationReport ValidateDirectory(string workspacePath)
return BuildReport(results, null);
}
- // Layer 1: XSD schema validation, one file at a time.
- // WebResources payloads (arbitrary XML uploaded as web resources) are skipped -
- // only their .data.xml descriptors have a schema.
- var schemaValidator = new SchemaValidator();
- ValidateFiles(workspacePath, "*.xml", ValidationStage.Schema,
- file => IsWebResourcePayload(file) ? Array.Empty() : schemaValidator.ValidateFile(file),
- results);
+ var solutionRoots = DiscoverSolutionRoots(workspacePath);
+ var workspaceIsSingleRoot = solutionRoots.Count == 0;
+ if (workspaceIsSingleRoot)
+ solutionRoots = new[] { workspacePath };
+
+ var solutionValidator = new SolutionValidator();
+
+ // File-level checks per solution root, then files outside any root, so every file is
+ // visited exactly once. Duplicate GUID detection stays workspace-wide: its component
+ // identity rules already tell cross-solution layering apart from real duplicates.
+ foreach (var root in solutionRoots)
+ solutionValidator.CollectSchemaAndJsonFindings(root, results);
+
+ if (!workspaceIsSingleRoot)
+ {
+ // Enumerated files and discovered roots share the workspacePath base, so ordinal
+ // prefix comparison is exact on every platform.
+ var rootPrefixes = solutionRoots
+ .Select(root => Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar)
+ .ToArray();
+
+ solutionValidator.CollectSchemaAndJsonFindings(workspacePath, results,
+ file =>
+ {
+ var fullFile = Path.GetFullPath(file);
+ return !rootPrefixes.Any(prefix => fullFile.StartsWith(prefix, StringComparison.Ordinal));
+ });
+ }
+
+ results.AddRange(SolutionValidator.WithStage(
+ new GuidValidator().ValidateDirectory(workspacePath), ValidationStage.DuplicateGuid));
- // Layer 2: JSON schema validation, one file at a time.
- ValidateFiles(workspacePath, "*.json", ValidationStage.Json, new JsonValidator().ValidateFile, results);
+ var loaded = new List<(string Root, Workspace? Workspace)>();
+ foreach (var root in solutionRoots)
+ loaded.Add((root, SolutionValidator.TryLoad(root, results)));
- // Layer 3: duplicate GUID detection across the whole tree.
- results.AddRange(WithStage(new GuidValidator().ValidateDirectory(workspacePath), ValidationStage.DuplicateGuid));
+ foreach (var (root, solution) in loaded)
+ {
+ if (solution != null)
+ solutionValidator.CollectModelFindingsSafe(solution, results, root);
+ }
- // Layer 4: load each solution into the model and validate it (load errors, flows, relationships).
- var workspace = ValidateModel(workspacePath, results);
+ CollectRelationshipFindings(loaded, results);
+ var workspace = BuildCombinedWorkspace(workspacePath, solutionRoots, loaded, results);
return BuildReport(results, workspace);
}
- private static bool IsWebResourcePayload(string filePath)
+ ///
+ /// Runs only the workspace-scoped relationship rules: every solution's relationships are
+ /// checked against the entities and columns of the whole workspace, since a relationship and
+ /// the entity it references may ship in different solutions. Meant for a single pre-build
+ /// pass over a multi-solution workspace; per-solution rules stay in .
+ ///
+ /// Path to the workspace root containing one or more unpacked solutions.
+ public WorkspaceValidationReport ValidateRelationships(string workspacePath)
{
- if (filePath.EndsWith(".data.xml", StringComparison.OrdinalIgnoreCase)) return false;
- var normalized = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
- return normalized.IndexOf($"{Path.DirectorySeparatorChar}WebResources{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0;
+ var results = new List();
+
+ if (!Directory.Exists(workspacePath))
+ {
+ results.Add(new ValidationResult(ValidationSeverity.Error,
+ $"Directory not found: {workspacePath}", null, null, null) { Stage = ValidationStage.Workspace });
+
+ return BuildReport(results, null);
+ }
+
+ var solutionRoots = DiscoverSolutionRoots(workspacePath);
+ if (solutionRoots.Count == 0)
+ solutionRoots = new[] { workspacePath };
+
+ var loaded = new List<(string Root, Workspace? Workspace)>();
+ foreach (var root in solutionRoots)
+ loaded.Add((root, SolutionValidator.TryLoad(root, results)));
+
+ CollectRelationshipFindings(loaded, results);
+
+ return BuildReport(results, null);
}
- private static void ValidateFiles(
- string workspacePath,
- string pattern,
- ValidationStage stage,
- Func> validate,
- List results)
+ private static void CollectRelationshipFindings(List<(string Root, Workspace? Workspace)> loaded, List results)
{
- foreach (var file in EnumerateWorkspaceFiles(workspacePath, pattern))
+ var workspaceColumns = BuildWorkspaceColumnSet(loaded.Select(l => l.Workspace).OfType());
+
+ foreach (var (root, solution) in loaded)
{
+ if (solution == null) continue;
+
try
{
- results.AddRange(WithStage(validate(file), stage));
+ results.AddRange(SolutionValidator.WithStage(
+ new RelationshipValidator().Validate(solution, workspaceColumns), ValidationStage.Relationship));
}
- catch (IOException ex)
+ catch (Exception ex)
{
- results.Add(new ValidationResult(ValidationSeverity.Warning,
- $"Cannot read file: {ex.Message}", file, null, null) { Stage = stage });
- }
- catch (UnauthorizedAccessException ex)
- {
- results.Add(new ValidationResult(ValidationSeverity.Warning,
- $"Access denied: {ex.Message}", file, null, null) { Stage = stage });
+ results.Add(new ValidationResult(
+ ValidationSeverity.Error,
+ $"Failed to load workspace into model: {ex.Message}",
+ root, null, null) { Stage = ValidationStage.ModelLoad });
}
}
}
- private static Workspace? ValidateModel(string workspacePath, List results)
+ private static Workspace? BuildCombinedWorkspace(
+ string workspacePath,
+ IReadOnlyList solutionRoots,
+ List<(string Root, Workspace? Workspace)> loaded,
+ List results)
{
+ if (loaded.Any(l => l.Workspace == null)) return null;
+ if (loaded.Count == 1) return loaded[0].Workspace;
+
try
{
- var reader = new XmlWorkspaceReader();
- var solutionRoots = DiscoverSolutionRoots(workspacePath);
- if (solutionRoots.Count == 0)
- solutionRoots = new[] { workspacePath };
-
- var loaded = solutionRoots.Select(reader.Load).ToList();
- var workspaceColumns = BuildWorkspaceColumnSet(loaded);
- foreach (var ws in loaded)
- CollectModelFindings(ws, workspaceColumns, results);
-
- return loaded.Count == 1
- ? loaded[0]
- : reader.LoadMany(solutionRoots.Select((path, index) => new SolutionWorkspaceSource(path, index)));
+ return new XmlWorkspaceReader().LoadMany(
+ solutionRoots.Select((path, index) => new SolutionWorkspaceSource(path, index)));
}
catch (Exception ex)
{
@@ -131,8 +155,6 @@ private static void ValidateFiles(
}
}
-
-
private static HashSet BuildWorkspaceColumnSet(IEnumerable workspaces)
{
var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -143,33 +165,7 @@ private static HashSet BuildWorkspaceColumnSet(IEnumerable wo
return columns;
}
- private static void CollectModelFindings(Workspace workspace, HashSet workspaceColumns, List results)
- {
- foreach (var loadError in workspace.LoadErrors)
- {
- results.Add(new ValidationResult(
- ValidationSeverity.Error,
- $"Load error: {loadError.Message}",
- loadError.FilePath,
- loadError.Line,
- loadError.Column) { Stage = ValidationStage.ModelLoad });
- }
-
- foreach (var diagnostic in workspace.FlowDefinitions.SelectMany(f => f.Diagnostics))
- {
- results.Add(new ValidationResult(
- MapFlowSeverity(diagnostic.Severity),
- $"Flow {diagnostic.Code}: {diagnostic.Message}",
- diagnostic.FilePath,
- diagnostic.Line,
- diagnostic.Column) { Stage = ValidationStage.Flow });
- }
-
- results.AddRange(WithStage(new RelationshipValidator().Validate(workspace, workspaceColumns), ValidationStage.Relationship));
- results.AddRange(new SolutionManifestValidator().Validate(workspace));
- }
-
- private static WorkspaceValidationReport BuildReport(IEnumerable results, Workspace? workspace)
+ internal static WorkspaceValidationReport BuildReport(IEnumerable results, Workspace? workspace)
{
var labeled = results
.Select(r => r with { Message = $"[{r.Stage.Label()}] {r.Message}" })
@@ -191,7 +187,7 @@ private static IReadOnlyList DiscoverSolutionRoots(string workspacePath)
var dir = pending.Pop();
foreach (var child in Directory.EnumerateDirectories(dir))
{
- if (IgnoredDirectories.Contains(Path.GetFileName(child)))
+ if (WorkspaceFiles.IgnoredDirectories.Contains(Path.GetFileName(child)))
continue;
if (File.Exists(Path.Combine(child, "Other", "Solution.xml")))
@@ -203,9 +199,6 @@ private static IReadOnlyList DiscoverSolutionRoots(string workspacePath)
return roots;
}
-
- private static IEnumerable WithStage(IEnumerable results, ValidationStage stage) => results.Select(r => r with { Stage = stage });
- private static ValidationSeverity MapFlowSeverity(FlowDiagnosticSeverity severity) => severity == FlowDiagnosticSeverity.Error ? ValidationSeverity.Error : ValidationSeverity.Warning;
}
///
diff --git a/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs b/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs
new file mode 100644
index 0000000..fdb487e
--- /dev/null
+++ b/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs
@@ -0,0 +1,76 @@
+using TALXIS.Platform.Metadata;
+using TALXIS.Platform.Metadata.Components;
+using TALXIS.Platform.Metadata.Components.Attributes;
+using TALXIS.Platform.Metadata.Serialization.Xml;
+using TALXIS.Platform.Metadata.Validation;
+
+namespace TALXIS.Platform.Metadata.Tests;
+
+public class SolutionValidatorTests
+{
+ private static readonly string SamplePath = Path.Combine(AppContext.BaseDirectory, "TestData", "SampleWorkspace");
+
+ [Fact]
+ public void Validate_NonExistentPath_ReturnsError()
+ {
+ var report = new SolutionValidator().Validate("/nonexistent/path/that/does/not/exist");
+
+ Assert.True(report.ErrorCount > 0);
+ Assert.Null(report.Workspace);
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Error && r.Message.Contains("Directory not found"));
+ }
+
+ [Fact]
+ public void Validate_SampleSolution_LoadsWorkspaceAndSummary()
+ {
+ var report = new SolutionValidator().Validate(SamplePath);
+
+ Assert.NotNull(report.Workspace);
+ Assert.NotNull(report.LoadedComponents);
+ Assert.True(report.LoadedComponents.Total > 0);
+ Assert.DoesNotContain(report.Results, r => r.Message.Contains("No Other/Solution.xml"));
+ }
+
+ [Fact]
+ public void Validate_MissingSolutionManifest_ReportsWarning()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"sol-test-manifest-{Guid.NewGuid():N}");
+ try
+ {
+ Directory.CreateDirectory(tempDir);
+
+ var report = new SolutionValidator().Validate(tempDir);
+
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Warning && r.Message.Contains("No Other/Solution.xml"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void RelationshipRules_NotPartOfSolutionValidation()
+ {
+ var ws = new Workspace("test");
+ var entity = new EntityMetadata { LogicalName = "pba_child" };
+ entity.AddAttribute(new LookupAttributeMetadata { LogicalName = "pba_lookup", IsCustomAttribute = true });
+ ws.AddEntity(entity);
+ ws.AddRelationship(new OneToManyRelationshipMetadata
+ {
+ SchemaName = "pba_parent_pba_child",
+ ReferencedEntity = "pba_parent",
+ ReferencedAttribute = "pba_parentid",
+ ReferencingEntity = "pba_child",
+ ReferencingAttribute = "pba_missing",
+ });
+
+ var missingDir = Path.Combine(Path.GetTempPath(), $"sol-test-none-{Guid.NewGuid():N}");
+ var results = new SolutionValidator().Validate(ws, missingDir).Results;
+
+ Assert.DoesNotContain(results, r => r.Message.Contains("pba_missing"));
+ Assert.DoesNotContain(results, r => r.Message.Contains("[Relationship]"));
+ }
+}
diff --git a/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs b/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs
index e69a1d4..b8b0cb8 100644
--- a/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs
+++ b/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs
@@ -179,4 +179,185 @@ public void FlowDiagnostics_SurfacedInReport()
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
}
}
+
+ [Fact]
+ public void ValidateDirectory_RelationshipFindings_StillIncluded()
+ {
+ var report = new WorkspaceValidator().ValidateDirectory(SamplePath);
+
+ Assert.Contains(report.Results, r => r.Message.Contains("[Relationship]"));
+ }
+
+ [Fact]
+ public void ValidateRelationships_ReportsOnlyRelationshipStage()
+ {
+ var report = new WorkspaceValidator().ValidateRelationships(SamplePath);
+
+ Assert.Contains(report.Results, r => r.Message.Contains("[Relationship]"));
+ Assert.DoesNotContain(report.Results, r => r.Message.Contains("[Schema]"));
+ Assert.DoesNotContain(report.Results, r => r.Message.Contains("[Solution]"));
+ }
+
+ [Fact]
+ public void MultiSolution_DuplicateGuidAcrossRoots_DifferentComponents_ReportsError()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-guid-{Guid.NewGuid():N}");
+ try
+ {
+ var guid = "{12345678-1234-1234-1234-123456789012}";
+ WriteSolution(tempDir, "SolA", ("Entities", "entity_one", "Form1.xml", guid));
+ WriteSolution(tempDir, "SolB", ("Entities", "entity_two", "Form2.xml", guid));
+
+ var report = new WorkspaceValidator().ValidateDirectory(tempDir);
+
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Error && r.Message.Contains("Duplicate GUID"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void MultiSolution_SameComponentInBothRoots_NotFlaggedAsDuplicate()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-layer-{Guid.NewGuid():N}");
+ try
+ {
+ var guid = "{12345678-1234-1234-1234-123456789012}";
+ WriteSolution(tempDir, "SolA", ("Entities", "shared_entity", "Form1.xml", guid));
+ WriteSolution(tempDir, "SolB", ("Entities", "shared_entity", "Form1.xml", guid));
+
+ var report = new WorkspaceValidator().ValidateDirectory(tempDir);
+
+ Assert.DoesNotContain(report.Results, r => r.Message.Contains("Duplicate GUID"));
+ Assert.NotNull(report.Workspace);
+ Assert.NotNull(report.LoadedComponents);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void MultiSolution_OneSolutionFailsToLoad_OthersStillValidated()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-partial-load-{Guid.NewGuid():N}");
+ try
+ {
+ WriteSolution(tempDir, "SolBroken");
+ var brokenEntityDir = Path.Combine(tempDir, "SolBroken", "Entities", "broken_entity");
+ Directory.CreateDirectory(brokenEntityDir);
+ File.WriteAllText(Path.Combine(brokenEntityDir, "Entity.xml"), "",
+ ""));
+
+ var report = new WorkspaceValidator().ValidateDirectory(tempDir);
+
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Error &&
+ r.Message.Contains("Failed to load workspace into model") &&
+ r.FilePath != null && r.FilePath.Contains("SolBroken"));
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Error &&
+ r.Message.Contains("tp_missing_entity"));
+ Assert.Null(report.Workspace);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void MultiSolution_FileOutsideAnyRoot_StillValidated()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-leftover-{Guid.NewGuid():N}");
+ try
+ {
+ WriteSolution(tempDir, "SolA");
+ File.WriteAllText(Path.Combine(tempDir, "stray.xml"), "
+ r.Severity == ValidationSeverity.Error &&
+ r.FilePath != null && r.FilePath.EndsWith("stray.xml", StringComparison.Ordinal));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void MultiSolution_DuplicateGuidWithinOneRoot_StillReported()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-intra-{Guid.NewGuid():N}");
+ try
+ {
+ var guid = "{12345678-1234-1234-1234-123456789012}";
+ WriteSolution(tempDir, "SolA",
+ ("Entities", "entity_one", "Form1.xml", guid),
+ ("Entities", "entity_two", "Form2.xml", guid));
+ WriteSolution(tempDir, "SolB", ("Entities", "entity_other", "Form3.xml", "{aaaaaaaa-bbbb-cccc-dddd-eeeeffff0000}"));
+
+ var report = new WorkspaceValidator().ValidateDirectory(tempDir);
+
+ Assert.Contains(report.Results, r =>
+ r.Severity == ValidationSeverity.Error && r.Message.Contains("Duplicate GUID"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ private static void WriteSolution(string workspaceDir, string uniqueName, params (string Folder, string Entity, string FormFile, string FormId)[] forms)
+ {
+ var solutionDir = Path.Combine(workspaceDir, uniqueName);
+ Directory.CreateDirectory(Path.Combine(solutionDir, "Other"));
+ File.WriteAllText(Path.Combine(solutionDir, "Other", "Solution.xml"), $"""
+
+
+
+ {uniqueName}
+
+
+
+
+ 1.0.0.0
+ 0
+
+ TestPub
+
+
+
+
+
+
+ tp
+ 10000
+
+
+
+
+
+
+ """);
+
+ foreach (var (folder, entity, formFile, formId) in forms)
+ {
+ var formDir = Path.Combine(solutionDir, folder, entity, "FormXml", "main");
+ Directory.CreateDirectory(formDir);
+ File.WriteAllText(Path.Combine(formDir, formFile),
+ $"\n{formId}");
+ }
+ }
}