From 670b607acb4fbd8d90404bad31a8fae1eb483096 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 12:43:17 -0400 Subject: [PATCH] fix(templateengine): SemanticValidator accepts composition-delegated CallScript (Children[x].CallScript leaf-name match) --- .../Validation/SemanticValidator.cs | 22 +++++++++- .../Validation/SemanticValidatorTests.cs | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs index adfee73d..35338588 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs @@ -40,6 +40,21 @@ public class SemanticValidator var scriptNames = new HashSet( configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal); + // Composition-delegated CallScript: a machine script may invoke a composed + // child's script via Children["X"].CallScript("Y") (often with a DYNAMIC child + // name, e.g. Children[side + "MESReceiver"]). ExtractCalls captures only the + // literal leaf "Y"; the actual flattened script is the composed canonical + // "X.Y". Since the child segment is dynamic it cannot be statically resolved, + // so we accept the call as existing when ANY composed script has that leaf + // name. The positional arg-count/type checks already self-skip for these + // (their canonical name "X.Y" is not the literal key "Y" in the param map). + var composedLeafNames = new HashSet( + configuration.Scripts + .Select(s => s.CanonicalName) + .Where(n => n.Contains('.')) + .Select(n => n[(n.LastIndexOf('.') + 1)..]), + StringComparer.Ordinal); + var sharedScriptNames = new HashSet( (sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal); @@ -92,8 +107,11 @@ public class SemanticValidator } else { - // CallScript targets must reference existing instance scripts - if (!scriptNames.Contains(call.TargetName)) + // CallScript targets must reference an existing instance script — + // either a same-scope sibling (canonical name) or a composition- + // delegated child script (leaf-name match; see composedLeafNames). + if (!scriptNames.Contains(call.TargetName) + && !composedLeafNames.Contains(call.TargetName)) { errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound, $"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.", diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs index 07d95be7..72546c30 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs @@ -82,6 +82,49 @@ public class SemanticValidatorTests Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } + [Fact] + public void Validate_CallScriptCompositionDelegatedByLeafName_NoError() + { + // A machine script delegating to a composed child's script via + // Children["X"].CallScript("MoveIn") captures only the leaf "MoveIn"; + // the flattened script is the composed canonical "LeftMESReceiver.MoveIn". + // The existence check must accept the leaf-name match (the child segment + // is typically dynamic and cannot be statically resolved). + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" }, + new ResolvedScript { CanonicalName = "RightMESReceiver.MoveIn", Code = "var x = 1;" }, + new ResolvedScript { CanonicalName = "MesMoveIn", Code = "await Children[side + \"MESReceiver\"].CallScript(\"MoveIn\", Parameters);" } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); + } + + [Fact] + public void Validate_CallScriptUnknownLeafName_StillReturnsError() + { + // Relaxation is leaf-name scoped: a target that matches neither a root + // canonical name nor any composed leaf name is still flagged. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Scripts = + [ + new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" }, + new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Typo\");" } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.CallTargetNotFound && e.Message.Contains("Typo")); + } + [Fact] public void Validate_CallSharedTargetNotFound_ReturnsError() {