feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block

This commit is contained in:
Joseph Doherty
2026-06-18 11:54:19 -04:00
parent 16cb078cd2
commit b3d99248fa
9 changed files with 755 additions and 14 deletions
@@ -1,5 +1,6 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
@@ -83,13 +84,25 @@ public class ValidationService
/// connection is checked against this set so a binding to a phantom/stale connection
/// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
/// </param>
/// <param name="resolveSchemaRef">
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
/// <c>lib:Name</c> reference target's name to the referenced schema JSON (or
/// <c>null</c> when the library entry does not exist). Supplied on the deploy path
/// (backed by <c>ISharedSchemaRepository</c>) so a dangling/cyclic/over-depth
/// <c>$ref</c> in any validated script parameter/return schema becomes a
/// deploy-blocking <see cref="ValidationCategory.SchemaReference"/> error naming the
/// missing reference. When <c>null</c> the seam is absent: schemas with no
/// <c>$ref</c> are unaffected (behavior unchanged), and a <c>$ref</c> with no
/// resolver is treated as dangling (the safe option).
/// </param>
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts = null,
IReadOnlySet<string>? alarmCapableConnectionNames = null,
bool enforceConnectionBindings = false,
IReadOnlySet<string>? siteConnectionNames = null)
IReadOnlySet<string>? siteConnectionNames = null,
Func<string, string?>? resolveSchemaRef = null)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -102,12 +115,86 @@ public class ValidationService
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
};
return ValidationResult.Merge(results.ToArray());
}
/// <summary>
/// M9-T32b — JSON-Schema <c>$ref</c> resolution check. Parses every script
/// parameter/return schema (instance scripts and the supplied shared scripts)
/// through <see cref="InboundApiSchema.ParseWithRefs"/>, resolving any
/// <c>{"$ref":"lib:Name"}</c> reference via <paramref name="resolveSchemaRef"/>.
/// A reference that cannot be resolved — dangling (the seam returns <c>null</c> or
/// none is supplied), cyclic, or over-depth — is a deploy-blocking
/// <see cref="ValidationCategory.SchemaReference"/> error naming the missing
/// reference and the owning script.
///
/// <para>
/// The check is INERT for schemas that contain no <c>$ref</c>:
/// <see cref="InboundApiSchema.ParseWithRefs"/> never consults the seam for them and
/// reports no unresolved refs, so the pre-existing validation behavior is unchanged
/// (this is the only edit to the schema validation path for non-<c>$ref</c> schemas).
/// A malformed (non-JSON) schema is left to the existing script-compilation /
/// semantic checks — this check swallows the parse exception and reports nothing,
/// so it never double-reports a structural problem as a missing reference.
/// </para>
/// </summary>
/// <param name="configuration">The flattened configuration whose scripts' schemas are checked.</param>
/// <param name="sharedScripts">Optional shared scripts whose schemas are also checked.</param>
/// <param name="resolveSchemaRef">The <c>$ref</c> resolution seam, or <c>null</c> (no resolver → refs dangle).</param>
/// <returns>A <see cref="ValidationResult"/> with one error per unresolved reference, or success.</returns>
internal static ValidationResult ValidateSchemaReferences(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts,
Func<string, string?>? resolveSchemaRef)
{
var errors = new List<ValidationEntry>();
foreach (var script in configuration.Scripts)
{
CheckSchema(script.CanonicalName, "parameter definitions", script.ParameterDefinitions);
CheckSchema(script.CanonicalName, "return definition", script.ReturnDefinition);
}
foreach (var shared in sharedScripts ?? [])
{
CheckSchema(shared.CanonicalName, "parameter definitions", shared.ParameterDefinitions);
CheckSchema(shared.CanonicalName, "return definition", shared.ReturnDefinition);
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
void CheckSchema(string scriptName, string schemaLabel, string? schemaJson)
{
if (string.IsNullOrWhiteSpace(schemaJson))
return;
SchemaParseResult parsed;
try
{
parsed = InboundApiSchema.ParseWithRefs(schemaJson, resolveSchemaRef);
}
catch (JsonException)
{
// Malformed schema JSON / over-depth nesting is surfaced by the other
// validation stages — not a missing-reference concern. Don't double-report.
return;
}
foreach (var missing in parsed.UnresolvedRefs)
{
errors.Add(ValidationEntry.Error(ValidationCategory.SchemaReference,
$"Script '{scriptName}' {schemaLabel} references schema 'lib:{missing}' which could not be resolved.",
scriptName));
}
}
}
/// <summary>
/// Validates that flattening produced a non-empty configuration.
/// </summary>