diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs
new file mode 100644
index 00000000..1e3152ea
--- /dev/null
+++ b/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs
@@ -0,0 +1,131 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Tests.Extensions
+{
+ using System;
+
+ using NUnit.Framework;
+
+ using SysML2.NET.CodeGenerator.Extensions;
+
+ ///
+ /// Covers the records that carry a correction or invariant, all of which refuse to be constructed
+ /// without a justification — the property that keeps the tables auditable.
+ ///
+ [TestFixture]
+ public class ErratumRecordsTestFixture
+ {
+ [Test]
+ public void VerifyOclErratum()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => new OclErratum(null, "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new OclErratum(" ", "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new OclErratum("original", null, "justification"), Throws.TypeOf());
+ Assert.That(() => new OclErratum("original", " ", "justification"), Throws.TypeOf());
+ Assert.That(() => new OclErratum("original", "replacement", null), Throws.TypeOf());
+ Assert.That(() => new OclErratum("original", "replacement", " "), Throws.TypeOf());
+ }
+
+ var erratum = new OclErratum("original", "replacement", "justification");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(erratum.Original, Is.EqualTo("original"));
+ Assert.That(erratum.Replacement, Is.EqualTo("replacement"));
+ Assert.That(erratum.Justification, Is.EqualTo("justification"));
+ }
+ }
+
+ [Test]
+ public void VerifyGrammarErratum()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => new GrammarErratum(null, "target", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarErratum(" ", "target", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarErratum("rule", null, "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarErratum("rule", " ", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarErratum("rule", "target", null), Throws.TypeOf());
+ Assert.That(() => new GrammarErratum("rule", "target", " "), Throws.TypeOf());
+ }
+
+ var erratum = new GrammarErratum("rule", "target", "justification");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(erratum.RuleName, Is.EqualTo("rule"));
+ Assert.That(erratum.TargetElementName, Is.EqualTo("target"));
+ Assert.That(erratum.Justification, Is.EqualTo("justification"));
+ }
+ }
+
+ [Test]
+ public void VerifyGrammarProductionErratum()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => new GrammarProductionErratum(null, "original", "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum(" ", "original", "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", null, "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", " ", "replacement", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", "original", null, "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", "original", " ", "justification"), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", "original", "replacement", null), Throws.TypeOf());
+ Assert.That(() => new GrammarProductionErratum("rule", "original", "replacement", " "), Throws.TypeOf());
+ }
+
+ var erratum = new GrammarProductionErratum("rule", "original", "replacement", "justification");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(erratum.RuleName, Is.EqualTo("rule"));
+ Assert.That(erratum.Original, Is.EqualTo("original"));
+ Assert.That(erratum.Replacement, Is.EqualTo("replacement"));
+ Assert.That(erratum.Justification, Is.EqualTo("justification"));
+ }
+ }
+
+ [Test]
+ public void VerifyNotationInvariant()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => new NotationInvariant(null, "metamodelName", "justification"), Throws.TypeOf());
+ Assert.That(() => new NotationInvariant(" ", "metamodelName", "justification"), Throws.TypeOf());
+ Assert.That(() => new NotationInvariant("name", null, "justification"), Throws.TypeOf());
+ Assert.That(() => new NotationInvariant("name", " ", "justification"), Throws.TypeOf());
+ Assert.That(() => new NotationInvariant("name", "metamodelName", null), Throws.TypeOf());
+ Assert.That(() => new NotationInvariant("name", "metamodelName", " "), Throws.TypeOf());
+ }
+
+ var invariant = new NotationInvariant("name", "metamodelName", "justification");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(invariant.Name, Is.EqualTo("name"));
+ Assert.That(invariant.MetamodelName, Is.EqualTo("metamodelName"));
+ Assert.That(invariant.Justification, Is.EqualTo("justification"));
+ }
+ }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs
new file mode 100644
index 00000000..e9d0b657
--- /dev/null
+++ b/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs
@@ -0,0 +1,108 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Tests.Extensions
+{
+ using System.Linq;
+
+ using NUnit.Framework;
+
+ using SysML2.NET.CodeGenerator.Extensions;
+
+ [TestFixture]
+ public class GrammarErrataTestFixture
+ {
+ ///
+ /// The production the CaseBodyItem erratum corrects, quoted exactly as the grammar carries it.
+ ///
+ private const string CaseBodyItemOriginal = "CaseBodyItem : Type =\r\n ActionBodyItem";
+
+ [Test]
+ public void VerifyApplyProductions()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(GrammarErrata.ApplyProductions(null), Is.Null);
+ Assert.That(GrammarErrata.ApplyProductions(string.Empty), Is.Empty);
+ Assert.That(GrammarErrata.ApplyProductions(" "), Is.EqualTo(" "));
+ }
+
+ // A grammar carrying none of the corrected productions is returned untouched.
+ const string unrelated = "Foo : Bar =\r\n Baz";
+
+ Assert.That(GrammarErrata.ApplyProductions(unrelated), Is.EqualTo(unrelated));
+
+ var corrected = GrammarErrata.ApplyProductions(CaseBodyItemOriginal);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(corrected, Is.Not.EqualTo(CaseBodyItemOriginal));
+ Assert.That(corrected, Does.Contain("CalculationBodyItem"));
+ Assert.That(corrected, Does.Not.Contain(" ActionBodyItem"));
+
+ // Re-applying a correction to already-corrected text is a no-op: the Original stops matching.
+ Assert.That(GrammarErrata.ApplyProductions(corrected), Is.EqualTo(corrected));
+ }
+
+ // The correction is applied verbatim wherever it appears, leaving surrounding text intact.
+ var embedded = GrammarErrata.ApplyProductions($"// leading\r\n{CaseBodyItemOriginal}\r\n// trailing");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(embedded, Does.StartWith("// leading"));
+ Assert.That(embedded, Does.EndWith("// trailing"));
+ Assert.That(embedded, Does.Contain("CalculationBodyItem"));
+ }
+ }
+
+ [Test]
+ public void VerifyQueryUnappliedErrata()
+ {
+ // ApplyProductions above marks the CaseBodyItem entry applied, so whatever remains must be
+ // reportable: every stale entry has to carry the rule name and the reason it was recorded.
+ var unapplied = GrammarErrata.QueryUnappliedErrata();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(unapplied, Is.Not.Null);
+ Assert.That(unapplied.All(erratum => !string.IsNullOrWhiteSpace(erratum.RuleName)), Is.True);
+ Assert.That(unapplied.All(erratum => !string.IsNullOrWhiteSpace(erratum.Justification)), Is.True);
+ }
+ }
+
+ [Test]
+ public void VerifyApplyTarget()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ // A target the grammar states itself always wins — an erratum only fills a gap.
+ Assert.That(GrammarErrata.ApplyTarget("LiteralReal", "AlreadyStated"), Is.EqualTo("AlreadyStated"));
+
+ // A rule with no erratum keeps its (absent) target.
+ Assert.That(GrammarErrata.ApplyTarget("NoSuchRule", null), Is.Null);
+ Assert.That(GrammarErrata.ApplyTarget(null, null), Is.Null);
+ Assert.That(GrammarErrata.ApplyTarget(" ", null), Is.Null);
+
+ // The KEBNF names this rule LiteralReal, but the metaclass is LiteralRational.
+ Assert.That(GrammarErrata.ApplyTarget("LiteralReal", null), Is.EqualTo("LiteralRational"));
+ }
+ }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs
new file mode 100644
index 00000000..79361482
--- /dev/null
+++ b/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs
@@ -0,0 +1,80 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Tests.Extensions
+{
+ using System.Linq;
+
+ using NUnit.Framework;
+
+ using SysML2.NET.CodeGenerator.Extensions;
+
+ [TestFixture]
+ public class NotationInvariantsTestFixture
+ {
+ [Test]
+ public void VerifyQueryMetamodelName()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(NotationInvariants.QueryMetamodelName(NotationInvariants.ResultMemberMetaclass), Is.EqualTo("ReturnParameterMembership"));
+ Assert.That(NotationInvariants.QueryMetamodelName(NotationInvariants.ImpliedDirectionProperty), Is.EqualTo("direction"));
+ Assert.That(NotationInvariants.QueryMetamodelName("NoSuchInvariant"), Is.Null);
+ Assert.That(NotationInvariants.QueryMetamodelName(null), Is.Null);
+ }
+ }
+
+ [Test]
+ public void VerifyQueryMetaclass()
+ {
+ // Without a cache source there is nothing to resolve against, and an unknown key has no name to
+ // resolve — neither may throw, because both simply disable the rule the invariant backs.
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, null), Is.Null);
+ Assert.That(NotationInvariants.QueryMetaclass("NoSuchInvariant", null), Is.Null);
+ Assert.That(NotationInvariants.QueryMetaclass(null, null), Is.Null);
+ }
+ }
+
+ [Test]
+ public void VerifyQueryUnresolvedInvariants()
+ {
+ NotationInvariants.MarkResolved(NotationInvariants.ImpliedDirectionProperty);
+
+ // Marking must be tolerant: a blank key records nothing rather than corrupting the set.
+ NotationInvariants.MarkResolved(null);
+ NotationInvariants.MarkResolved(" ");
+
+ var unresolved = NotationInvariants.QueryUnresolvedInvariants();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(unresolved, Is.Not.Null);
+ Assert.That(unresolved.Any(invariant => invariant.Name == NotationInvariants.ImpliedDirectionProperty), Is.False);
+
+ // An unresolved entry has to be actionable: it names the anchor that went missing and why it
+ // mattered, because the rule it backs is silently off until it is re-anchored.
+ Assert.That(unresolved.All(invariant => !string.IsNullOrWhiteSpace(invariant.MetamodelName)), Is.True);
+ Assert.That(unresolved.All(invariant => !string.IsNullOrWhiteSpace(invariant.Justification)), Is.True);
+ }
+ }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj b/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj
index a4f371c3..3ea2b698 100644
--- a/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj
+++ b/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj
@@ -19,6 +19,7 @@
+
diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs
index 1157dc54..ca9c7856 100644
--- a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs
+++ b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs
@@ -25,25 +25,30 @@ namespace SysML2.NET.CodeGenerator.Extensions
using System.Linq;
///
- /// Supplies the target metaclass for KEBNF rules whose name does not match the metaclass they build,
- /// at generation time.
+ /// Corrects known defects in the KEBNF grammar carried under Resources/, at generation time.
///
///
- /// The KEBNF files under Resources/ are OMG source and are never edited, and the generated
- /// output is never hand-edited either — so a rule that omits a target the generator cannot infer can
- /// only be corrected here, on the way from the one to the other. The files reproduce the
- /// textual-notation BNF of the KerML and SysML specifications verbatim, so a defect here is a
- /// SPECIFICATION defect; OMG has confirmed this class of finding and routes the fix through the
- /// Revision Task Forces (Systems-Modeling/SysML-v2-Release issue 124).
- /// The grammar writes an explicit target whenever the rule name differs from the metaclass
- /// (RequirementKind : RequirementConstraintMembership, SubjectMember : SubjectMembership).
- /// Every entry below is a rule where that annotation is missing, so the rule name resolves to no
- /// metaclass at all and the generator falls back to inferring one from the assigned property names —
- /// which silently selects an unrelated class that happens to declare the same property.
- /// Scope is deliberately narrow: an entry corrects a rule the generator would otherwise bind to
- /// the WRONG metaclass. A production that merely admits more than one valid spelling is NOT an
- /// erratum — choosing between admissible spellings is the writer's business, not a correction to the
- /// grammar.
+ /// The KEBNF files are OMG source and are never edited, and the generated output is never hand-edited
+ /// either — so a defect can only be corrected here, on the way from the one to the other. The files are
+ /// mechanically extracted from the specification document, while the pilot implementation's parser is a
+ /// separately hand-maintained Xtext grammar; the two drift, and a defect here is a SPECIFICATION defect.
+ /// OMG has confirmed this class of finding and routes the fix through the Revision Task Forces
+ /// (Systems-Modeling/SysML-v2-Release issue 124).
+ /// Two kinds of correction, because the defects differ in kind:
+ /// — a rule whose name does not match the metaclass it builds and which
+ /// omits the explicit target the grammar normally writes in that case
+ /// (RequirementKind : RequirementConstraintMembership). Without it the rule name resolves to no
+ /// metaclass, and the generator falls back to inferring one from the assigned property names, silently
+ /// selecting an unrelated class that happens to declare the same property.
+ /// — a production whose token sequence cannot derive notation the
+ /// metamodel and the specification's own normative examples require. Applied to the grammar TEXT before
+ /// it is parsed, so the corrected production flows through the normal pipeline and nothing downstream
+ /// needs to special-case the rule.
+ /// Scope is deliberately narrow, and the bar for both kinds is the same: the generator would
+ /// otherwise produce output that is WRONG, not merely different. A production that admits more than one
+ /// valid spelling is NOT an erratum — choosing between admissible spellings is the writer's business.
+ /// The reference test is whether the pilot's Xtext grammar accepts what we emit: where it does, any
+ /// difference is a style choice; where it cannot, the grammar is genuinely deficient.
/// These corrections are expected to become unnecessary as OMG publishes fixes. On a new KEBNF
/// release, run the generator and prune whatever reports — an entry
/// that no longer matches has been fixed upstream.
@@ -59,6 +64,34 @@ public static class GrammarErrata
"KerML 8.2.2.24 writes 'LiteralReal = value = RealValue' with no target, but no metaclass named 'LiteralReal' exists — KerML 8.3.4.9 names it 'LiteralRational'. Its sibling literal rules (LiteralBoolean, LiteralString, LiteralInteger, LiteralInfinity) all match a metaclass by name, so only this one is left unresolved.")
];
+ ///
+ /// The production-text corrections applied to the grammar before it is parsed.
+ ///
+ ///
+ /// An entry belongs here only when the grammar cannot derive the notation at all. A production that
+ /// merely admits a spelling we do not emit is NOT an erratum — see the class remarks.
+ ///
+ private static readonly GrammarProductionErratum[] ProductionEntries =
+ [
+ new("CaseBodyItem",
+ "CaseBodyItem : Type =\r\n ActionBodyItem",
+ "CaseBodyItem : Type =\r\n CalculationBodyItem",
+ "SysML 8.2.2.22.1 gives CaseBodyItem the alternative 'ActionBodyItem', which reaches no " +
+ "ReturnParameterMember, so 'return' cannot be written in a case body. Three independent " +
+ "sources say it must be: (1) the pilot implementation's own grammar uses " +
+ "'CalculationBodyItem' here (org.omg.sysml.xtext SysML.xtext, rule CaseBodyItem), and " +
+ "CalculationBodyItem = ActionBodyItem | ReturnParameterMember; (2) the metamodel permits it " +
+ "— constraint validateReturnParameterMembershipOwningType requires the owningType of a " +
+ "ReturnParameterMembership to be a Function or Expression, and VerificationCaseUsage " +
+ "specializes CaseUsage specializes CalculationUsage specializes Expression; (3) the " +
+ "normative example in SysML 7.24.2 writes 'return verdict : VerdictKind = " +
+ "evaluateData.verdict;' inside a 'verification def' body. There is no admissible " +
+ "alternative spelling: rendering the ReturnParameterMembership through the generic " +
+ "parameter path emits 'out verdict', which re-parses as a plain FeatureMembership with " +
+ "direction out and so loses the metaclass. CalculationBodyItem is already declared in the " +
+ "same file, so the replacement resolves without any further correction.")
+ ];
+
///
/// The corrections that have matched at least one rule during this generator run.
///
@@ -97,16 +130,57 @@ public static string ApplyTarget(string ruleName, string targetElementName)
}
///
- /// Returns the corrections that matched no rule during this generator run.
+ /// Applies every known production correction to the raw text of a KEBNF file.
+ ///
+ /// The grammar text as read from disk.
+ ///
+ /// The corrected grammar text, or unchanged when nothing applies.
+ ///
+ ///
+ /// Correcting the text rather than the parsed rule keeps the correction in the grammar's own
+ /// language: the entry reads as the production OMG should have written, and every consumer parses
+ /// it exactly as it parses the rest of the file. Each Original is matched verbatim, so a
+ /// correction cannot partially match, and re-applying it to already-corrected text is a no-op.
+ /// Both KEBNF files are passed through this, so an entry only fires against the file that carries
+ /// its production.
+ ///
+ public static string ApplyProductions(string kebnfSource)
+ {
+ if (string.IsNullOrWhiteSpace(kebnfSource))
+ {
+ return kebnfSource;
+ }
+
+ return ProductionEntries
+ .Where(erratum => kebnfSource.Contains(erratum.Original, StringComparison.Ordinal))
+ .Aggregate(kebnfSource, (corrected, erratum) =>
+ {
+ AppliedRuleNames.Add(erratum.RuleName);
+
+ return corrected.Replace(erratum.Original, erratum.Replacement);
+ });
+ }
+
+ ///
+ /// Returns the corrections that matched nothing during this generator run.
///
- /// The stale entries, which should be pruned from .
+ /// The stale entries, which should be pruned.
///
- /// Only meaningful once every rule has been read. A stale entry means the grammar no longer carries
- /// the defect — either OMG annotated the rule, or the rule was renamed or removed.
+ /// Only meaningful once every grammar file has been loaded and every rule read. A stale entry means
+ /// the grammar no longer carries the defect — either OMG corrected it, or the rule was renamed or
+ /// removed.
///
- public static IReadOnlyList QueryUnappliedErrata()
+ public static IReadOnlyList<(string RuleName, string Justification)> QueryUnappliedErrata()
{
- return [..Entries.Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName))];
+ return
+ [
+ ..Entries
+ .Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName))
+ .Select(erratum => (erratum.RuleName, erratum.Justification)),
+ ..ProductionEntries
+ .Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName))
+ .Select(erratum => (erratum.RuleName, erratum.Justification))
+ ];
}
}
}
diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs
new file mode 100644
index 00000000..36b4d5a9
--- /dev/null
+++ b/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs
@@ -0,0 +1,86 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Extensions
+{
+ using System;
+
+ ///
+ /// A single correction applied to the text of a KEBNF production before it is parsed.
+ ///
+ public sealed class GrammarProductionErratum
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The rule the correction belongs to, used when reporting staleness.
+ /// The exact production text the grammar carries.
+ /// The text it is corrected to.
+ /// The evidence that the original is a defect rather than intent.
+ /// Thrown when any argument is null or whitespace.
+ public GrammarProductionErratum(string ruleName, string original, string replacement, string justification)
+ {
+ if (string.IsNullOrWhiteSpace(ruleName))
+ {
+ throw new ArgumentException("The rule name is required.", nameof(ruleName));
+ }
+
+ if (string.IsNullOrWhiteSpace(original))
+ {
+ throw new ArgumentException("The original production text is required.", nameof(original));
+ }
+
+ if (string.IsNullOrWhiteSpace(replacement))
+ {
+ throw new ArgumentException("The replacement production text is required.", nameof(replacement));
+ }
+
+ if (string.IsNullOrWhiteSpace(justification))
+ {
+ throw new ArgumentException("A justification is required so the correction can be audited.", nameof(justification));
+ }
+
+ this.RuleName = ruleName;
+ this.Original = original;
+ this.Replacement = replacement;
+ this.Justification = justification;
+ }
+
+ ///
+ /// Gets the rule the correction belongs to.
+ ///
+ public string RuleName { get; }
+
+ ///
+ /// Gets the exact production text the grammar carries.
+ ///
+ public string Original { get; }
+
+ ///
+ /// Gets the text it is corrected to.
+ ///
+ public string Replacement { get; }
+
+ ///
+ /// Gets the evidence that the original is a defect rather than intent.
+ ///
+ public string Justification { get; }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs b/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs
new file mode 100644
index 00000000..f36765ef
--- /dev/null
+++ b/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs
@@ -0,0 +1,79 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Extensions
+{
+ using System;
+
+ ///
+ /// A single notation invariant: a rule the writer must honour that neither the KEBNF nor the metamodel
+ /// states machine-readably, anchored to the OMG name it depends on.
+ ///
+ public sealed class NotationInvariant
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stable key the generator refers to the invariant by.
+ /// The OMG metaclass or property name the invariant depends on.
+ /// Why the invariant holds, and what breaks without it.
+ /// Thrown when any argument is null or whitespace.
+ public NotationInvariant(string name, string metamodelName, string justification)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("The invariant name is required.", nameof(name));
+ }
+
+ if (string.IsNullOrWhiteSpace(metamodelName))
+ {
+ throw new ArgumentException("The metamodel name is required.", nameof(metamodelName));
+ }
+
+ if (string.IsNullOrWhiteSpace(justification))
+ {
+ throw new ArgumentException("A justification is required so the invariant can be audited.", nameof(justification));
+ }
+
+ this.Name = name;
+ this.MetamodelName = metamodelName;
+ this.Justification = justification;
+ }
+
+ ///
+ /// Gets the stable key the generator refers to the invariant by.
+ ///
+ ///
+ /// Deliberately independent of : the generator names the CONCEPT, so an
+ /// OMG rename is a single edit here rather than a hunt through the emission code.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the OMG metaclass or property name the invariant depends on.
+ ///
+ public string MetamodelName { get; }
+
+ ///
+ /// Gets the reason the invariant holds, and what breaks without it.
+ ///
+ public string Justification { get; }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs b/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs
new file mode 100644
index 00000000..26e7cd9e
--- /dev/null
+++ b/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs
@@ -0,0 +1,143 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Extensions
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ using SysML2.NET.CodeGenerator.HandleBarHelpers;
+
+ using uml4net.CommonStructure;
+ using uml4net.StructuredClassifiers;
+
+ ///
+ /// The OMG names the textual-notation generator depends on for rules that neither the KEBNF nor the
+ /// metamodel states machine-readably.
+ ///
+ ///
+ /// Some notation rules cannot be derived. The grammar describes token order, and the metamodel describes
+ /// structure, but neither records that a KEYWORD already conveys a property, nor that a member is always
+ /// singular. The generator therefore has to know a small number of OMG names outright.
+ /// Holding them here rather than inline achieves the two things that matter on an OMG release:
+ /// every dependency is auditable in ONE place with its justification, and a name that stops resolving is
+ /// REPORTED () instead of silently disabling the rule it backs —
+ /// which is how the emission bugs these invariants fix would quietly return.
+ /// Prefer derivation whenever it exists; add an entry only when it does not. Two derivations were
+ /// tried and rejected for the direction rule below: the metamodel's own
+ /// ParameterMembership::parameterDirection() is polymorphic but returns in on the base, so
+ /// suppressing whenever the direction matches it would strip every in parameter; and the "must be
+ /// out" statement on ReturnParameterMembership is class documentation, not one of its OCL constraints.
+ ///
+ public static class NotationInvariants
+ {
+ ///
+ /// The metaclass of a member that the enclosing rule always consumes on its own.
+ ///
+ public const string ResultMemberMetaclass = "ResultMemberMetaclass";
+
+ ///
+ /// The Feature property whose value a result member's own keyword already conveys.
+ ///
+ public const string ImpliedDirectionProperty = "ImpliedDirectionProperty";
+
+ ///
+ /// The OMG names the generator depends on, keyed by the concept the generator refers to.
+ ///
+ private static readonly NotationInvariant[] Entries =
+ [
+ new(ResultMemberMetaclass, "ReturnParameterMembership",
+ "The grammar never repeats a result member: it always gives one its OWN slot in the enclosing rule (EmptyResultMember, ConstructorResultMember, ReturnParameterMember), never a comma-separated repetition. A repeated '+=' member that shares the enclosing rule's cursor therefore has to exclude it, because the repetition's declared item type is one of its supertypes — ArgumentMember is a ParameterMembership and EmptyResultMember is a ReturnParameterMembership, so the loop consumed the result member, emitted a separator and then rendered nothing: 'f(a, )'."),
+ new(ImpliedDirectionProperty, "direction",
+ "The 'return' keyword of ReturnParameterMember already says the parameter is the result, and a result parameter always carries direction = out, so writing the direction as well emits 'return out verdict' where the notation is 'return verdict'. The exclusion cannot go through QuerySubclassesWithMatchingDefault, which suppresses a keyword whose metamodel DEFAULT already matches: Feature::direction is [0..1] and declares no default, so there is nothing to compare against and a null direction genuinely means undirected.")
+ ];
+
+ ///
+ /// The invariants whose OMG name resolved against the metamodel during this generator run.
+ ///
+ private static readonly HashSet ResolvedNames = [];
+
+ ///
+ /// Returns the OMG name an invariant depends on.
+ ///
+ /// The invariant's stable key.
+ /// The OMG name, or when no entry carries that key.
+ public static string QueryMetamodelName(string invariantName)
+ {
+ return Entries.SingleOrDefault(invariant => string.Equals(invariant.Name, invariantName, StringComparison.Ordinal))?.MetamodelName;
+ }
+
+ ///
+ /// Resolves the metaclass an invariant depends on, recording that its name still exists.
+ ///
+ /// The invariant's stable key.
+ /// Any from the loaded model, used to reach the cache.
+ /// The resolved , or when it no longer exists.
+ ///
+ /// A null return disables the rule the invariant backs, which is why the miss is recorded rather than
+ /// swallowed: turns it into a message on the next run.
+ ///
+ public static IClass QueryMetaclass(string invariantName, IClass cacheSource)
+ {
+ var metamodelName = QueryMetamodelName(invariantName);
+
+ if (metamodelName == null || cacheSource == null)
+ {
+ return null;
+ }
+
+ var metaclass = RuleQueryUtilities.FindClass(cacheSource.Cache, metamodelName);
+
+ if (metaclass != null)
+ {
+ ResolvedNames.Add(invariantName);
+ }
+
+ return metaclass;
+ }
+
+ ///
+ /// Records that an invariant's property name still matches a property the grammar assigns.
+ ///
+ /// The invariant's stable key.
+ public static void MarkResolved(string invariantName)
+ {
+ if (!string.IsNullOrWhiteSpace(invariantName))
+ {
+ ResolvedNames.Add(invariantName);
+ }
+ }
+
+ ///
+ /// Returns the invariants whose OMG name resolved against nothing during this generator run.
+ ///
+ /// The unresolved entries, whose rules are consequently NOT being applied.
+ ///
+ /// Only meaningful once generation has completed. An unresolved entry means OMG renamed or removed
+ /// the name the invariant hangs on, so the emission rule it backs is silently off and the entry needs
+ /// re-anchoring — not pruning, since the underlying notation rule still holds.
+ ///
+ public static IReadOnlyList QueryUnresolvedInvariants()
+ {
+ return [..Entries.Where(invariant => !ResolvedNames.Contains(invariant.Name))];
+ }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
index c87d4427..29812bdb 100644
--- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
+++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
@@ -123,6 +123,14 @@ public async Task GenerateAsync(XmiReaderResult xmiReaderResult, TextualNotation
await this.GenerateBuilderClasses(xmiReaderResult, textualNotationSpecification, outputDirectory);
await this.GenerateSharedBuilder(xmiReaderResult, textualNotationSpecification, outputDirectory);
// await this.GenerateBuilderFacade(xmiReaderResult, outputDirectory);
+
+ // Every rule has now been generated, so an invariant whose OMG name resolved against nothing is
+ // no longer being applied. Reported rather than thrown: the emission rule it backs is off, which
+ // is a defect to re-anchor, but it must not stop generation.
+ foreach (var unresolved in NotationInvariants.QueryUnresolvedInvariants())
+ {
+ Console.WriteLine($"[NotationInvariants] UNRESOLVED — {unresolved.Name} is anchored to '{unresolved.MetamodelName}', which the metamodel no longer carries, so the rule it backs is NOT applied. Recorded reason: {unresolved.Justification}");
+ }
}
///
diff --git a/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs b/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs
index 9aa3d884..8272d24f 100644
--- a/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs
+++ b/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs
@@ -24,6 +24,7 @@ namespace SysML2.NET.CodeGenerator.Grammar
using Antlr4.Runtime;
+ using SysML2.NET.CodeGenerator.Extensions;
using SysML2.NET.CodeGenerator.Grammar.Model;
///
@@ -44,7 +45,9 @@ public static TextualNotationSpecification LoadTextualNotationSpecification(stri
throw new FileNotFoundException("File not found", fileUri);
}
- var stream = CharStreams.fromPath(fileUri);
+ // The KEBNF files are OMG source and are never edited, so a defective production is corrected
+ // here, on the way into the parser — see GrammarErrata.ApplyProductions.
+ var stream = CharStreams.fromString(GrammarErrata.ApplyProductions(File.ReadAllText(fileUri)));
var lexer = new kebnfLexer(stream);
var tokens = new CommonTokenStream(lexer);
var parser = new kebnfParser(tokens);
diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
index 18e9c9d0..6d93e911 100644
--- a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
+++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
@@ -21,6 +21,7 @@
namespace SysML2.NET.CodeGenerator.HandleBarHelpers
{
using System;
+ using System.Collections.Generic;
using System.Linq;
using HandlebarsDotNet;
@@ -99,7 +100,7 @@ internal void ProcessRuleElement(EncodedTextWriter writer, IClass umlClass, Rule
}
else
{
- groupTypeGuard = $" && {cursorToUse.CursorVariableName}.Current is {targetClass.QueryFullyQualifiedTypeName()}";
+ groupTypeGuard = $" && {cursorToUse.CursorVariableName}.Current is {targetClass.QueryFullyQualifiedTypeName()}{ResolveResultMemberExclusion(cursorToUse, targetClass, umlClass)}";
}
}
}
@@ -313,6 +314,51 @@ private static string ResolveTrailingConsumptionReservation(CursorDefinition cur
: $" && {cursorDefinition.CursorVariableName}.GetNext(1) is {trailingTypeName}";
}
+ ///
+ /// Builds the extra while clause keeping a repeated += member from consuming a
+ /// ReturnParameterMembership.
+ ///
+ /// The cursor the repeated group consumes from.
+ /// The class the repetition's own type guard tests for.
+ /// The class hosting the current rule (provides the UML cache).
+ /// The additional guard clause, or an empty string when the repetition cannot match one.
+ ///
+ /// The grammar never repeats a result member: it always gives one its OWN slot in the enclosing rule
+ /// (EmptyResultMember, ConstructorResultMember, ReturnParameterMember), never a
+ /// comma-separated repetition. A repetition that shares the enclosing rule's cursor will therefore
+ /// swallow it whenever the repetition's guard type happens to be one of its supertypes:
+ ///
+ /// InvocationExpression = ownedRelationship += InstantiatedTypeMember
+ /// ArgumentList
+ /// ownedRelationship += EmptyResultMember
+ /// PositionalArgumentList = ownedRelationship += ArgumentMember
+ /// ( ',' ownedRelationship += ArgumentMember )*
+ ///
+ /// ArgumentMember : ParameterMembership and EmptyResultMember : ReturnParameterMembership
+ /// — a ParameterMembership — so the loop emits a separator for it and then renders nothing:
+ /// f(a, ).
+ /// Gating on subtype overlap keeps this general without touching repetitions it cannot affect.
+ /// Of the fifteen comma-repetition shapes across both grammars only two — ArgumentMember and
+ /// NamedArgumentMember — guard on a supertype of ReturnParameterMembership; the rest test
+ /// relationship types or sibling membership subtypes, for which the clause would be dead code.
+ ///
+ private static string ResolveResultMemberExclusion(CursorDefinition cursorDefinition, IClass itemTargetClass, IClass umlClass)
+ {
+ var resultMemberClass = NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, umlClass);
+
+ if (resultMemberClass == null || itemTargetClass == null)
+ {
+ return string.Empty;
+ }
+
+ var repetitionAdmitsResultMember = string.Equals(resultMemberClass.Name, itemTargetClass.Name, StringComparison.Ordinal)
+ || resultMemberClass.QueryAllGeneralClassifiers().Any(general => string.Equals(general.Name, itemTargetClass.Name, StringComparison.Ordinal));
+
+ return repetitionAdmitsResultMember
+ ? $" && {cursorDefinition.CursorVariableName}.Current is not {resultMemberClass.QueryFullyQualifiedTypeName()}"
+ : string.Empty;
+ }
+
///
/// Processes an
///
@@ -456,8 +502,39 @@ internal void ProcessAssignmentElement(EncodedTextWriter writer, IClass umlClass
}
else if (targetProperty.QueryIsEnum())
{
+ // A result member's own keyword already conveys the direction, so writing it
+ // again emits `return out verdict` where the notation is `return verdict`.
+ // Testing the owning Membership follows the metamodel's own idiom — Feature's
+ // parameter-redefinition constraint selects parameters with `direction <> null`
+ // and then rejects those whose owningFeatureMembership is a result membership.
+ // Both OMG names come from NotationInvariants, which reports either of them
+ // going missing rather than letting this rule switch itself off.
+ var impliedDirectionProperty = NotationInvariants.QueryMetamodelName(NotationInvariants.ImpliedDirectionProperty);
+ var isImpliedDirectionProperty = string.Equals(targetProperty.Name, impliedDirectionProperty, StringComparison.Ordinal);
+
+ if (isImpliedDirectionProperty)
+ {
+ // Marked on the NAME matching, independently of the metaclass below, so a
+ // report names only the anchor that actually went missing.
+ NotationInvariants.MarkResolved(NotationInvariants.ImpliedDirectionProperty);
+ }
+
+ var resultMemberClass = NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, umlClass);
+ var suppressForResultParameter = isImpliedDirectionProperty && resultMemberClass != null;
+
+ if (suppressForResultParameter)
+ {
+ writer.WriteSafeString($"if (poco.owningMembership is not {resultMemberClass.QueryFullyQualifiedTypeName()}){Environment.NewLine}");
+ writer.WriteSafeString($"{{{Environment.NewLine}");
+ }
+
writer.WriteSafeString($"stringBuilder.Append(poco.{targetPropertyName}.ToString().ToLower());{Environment.NewLine}");
writer.WriteSafeString("stringBuilder.Append(' ');");
+
+ if (suppressForResultParameter)
+ {
+ writer.WriteSafeString($"{Environment.NewLine}}}");
+ }
}
else if (targetProperty.QueryIsReferenceType())
{
diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml
new file mode 100644
index 00000000..ba922d98
--- /dev/null
+++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml
@@ -0,0 +1,82 @@
+package '9-Verification-simplified' {
+ private import VerificationCases::*;
+ private import Definitions::*;
+ package Definitions {
+ requirement def <'2'> MassRequirement {
+ attribute massActual :> ISQ::mass;
+ attribute massReqd :> ISQ::mass;
+ doc
+ /* The actual mass shall be less than or equal to the required mass limit. */
+ require constraint { massActual <= massReqd }
+ }
+ part def Vehicle {
+ attribute mass :> ISQ::mass;
+ }
+ part def MassVerificationSystem;
+ part def Scale;
+ part def TestOperator;
+ individual def TestVehicle1 :> Vehicle;
+ individual def TestVehicle2 :> Vehicle;
+ individual def TestSystem :> MassVerificationSystem;
+ verification def MassTest {
+ objective massVerificationObjective {
+ verify requirement massRequirement: '2';
+ }
+ }
+ }
+ package Usages {
+ requirement <'2.1'> vehicleMassRequirement: '2' {
+ subject vehicle: Vehicle;
+ doc
+ /* The vehicle mass shall be less than or equal to 2500 kg. */
+ :>> massActual = vehicle.mass;
+ :>> massReqd = 2500[SI::kg];
+ }
+ part vehicle1_c2: Vehicle;
+ verification vehicleMassTest: MassTest {
+ subject testVehicle: Vehicle;
+ objective vehicleMassVerificationObjective {
+ verify '2.1' :>> massRequirement;
+ }
+ action collectData {
+ in part testVehicle: Vehicle = vehicleMassTest.testVehicle;
+ out massMeasured :> ISQ::mass;
+ }
+ action processData {
+ in massMeasured :> ISQ::mass = collectData.massMeasured;
+ out massProcessed :> ISQ::mass;
+ }
+ action evaluateData {
+ in massProcessed :> ISQ::mass = processData.massProcessed;
+ out verdict: VerdictKind = PassIf('2.1'(vehicle = new testVehicle(mass = massProcessed)));
+ }
+ return verdict: VerdictKind = evaluateData.verdict;
+ }
+ part massVerificationSystem: MassVerificationSystem {
+ perform vehicleMassTest {
+ in part :>> testVehicle = vehicleUnderTest;
+ }
+ ref part vehicleUnderTest: Vehicle;
+ part testOperator: TestOperator;
+ part scale: Scale {
+ perform vehicleMassTest.collectData {
+ in part :>> testVehicle;
+ ref measurement = testVehicle.mass;
+ out :>> massMeasured = measurement;
+ }
+ }
+ }
+ individual testSystem: TestSystem :> massVerificationSystem {
+ timeslice test1 {
+ ref individual :>> vehicleUnderTest : TestVehicle1 :> vehicle1_c2 {
+ :>> mass = 2500[SI::kg];
+ }
+ }
+ then timeslice test2 {
+ ref individual :>> vehicleUnderTest : TestVehicle2 :> vehicle1_c2 {
+ :>> mass = 2500[SI::kg];
+ }
+ }
+ }
+ }
+}
diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
index c93170c9..52b2a05f 100644
--- a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
+++ b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
@@ -105,6 +105,7 @@ public void OneTimeTearDown()
[TestCase("07-Variant Configuration", "7a1-Variant Configuration - General Concept-a.sysmlx")]
[TestCase("07-Variant Configuration", "7b-Variant Configurations.sysmlx")]
[TestCase("08-Requirements", "8-Requirements.sysmlx")]
+ [TestCase("09-Verification", "9-Verification-simplified.sysmlx")]
public async Task VerifyValidationTextualNotationXmi(string folderName, string fileName)
{
var loggerFactory = LoggerFactory.Create(builder =>
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs
index 3f1ddc1e..dd752e05 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs
@@ -609,8 +609,11 @@ public static void BuildBasicFeaturePrefix(SysML2.NET.Core.POCO.Core.Features.IF
if (poco.Direction.HasValue)
{
- stringBuilder.Append(poco.Direction.ToString().ToLower());
- stringBuilder.Append(' ');
+ if (poco.owningMembership is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership)
+ {
+ stringBuilder.Append(poco.Direction.ToString().ToLower());
+ stringBuilder.Append(' ');
+ }
stringBuilder.Append(' ');
}
@@ -1053,7 +1056,7 @@ public static void BuildPositionalArgumentList(SysML2.NET.Core.POCO.Core.Feature
}
}
- while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Kernel.Behaviors.IParameterMembership)
+ while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Kernel.Behaviors.IParameterMembership && ownedRelationshipCursor.Current is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership)
{
stringBuilder.Append(", ");
@@ -1093,7 +1096,7 @@ public static void BuildNamedArgumentList(SysML2.NET.Core.POCO.Core.Features.IFe
}
}
- while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Types.IFeatureMembership)
+ while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Types.IFeatureMembership && ownedRelationshipCursor.Current is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership)
{
stringBuilder.Append(", ");
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs
index b2ab917e..8b1acd1f 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs
@@ -387,7 +387,7 @@ public static void BuildCaseBody(SysML2.NET.Core.POCO.Core.Types.IType poco, Tex
///
/// Builds the Textual Notation string for the rule CaseBodyItem
- /// CaseBodyItem:Type=ActionBodyItem|ownedRelationship+=SubjectMember|ownedRelationship+=ActorMember|ownedRelationship+=ObjectiveMember
+ /// CaseBodyItem:Type=CalculationBodyItem|ownedRelationship+=SubjectMember|ownedRelationship+=ActorMember|ownedRelationship+=ObjectiveMember
///
/// The from which the rule should be build
/// The providing the serialization context for the current
@@ -412,7 +412,7 @@ public static void BuildCaseBodyItem(SysML2.NET.Core.POCO.Core.Types.IType poco,
}
else
{
- BuildActionBodyItem(poco, writerContext, stringBuilder);
+ BuildCalculationBodyItem(poco, writerContext, stringBuilder);
}
}
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs
index b491b39f..66a1a9d7 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs
@@ -66,8 +66,11 @@ public static void BuildRefPrefix(SysML2.NET.Core.POCO.Systems.DefinitionAndUsag
if (poco.Direction.HasValue)
{
- stringBuilder.Append(poco.Direction.ToString().ToLower());
- stringBuilder.Append(' ');
+ if (poco.owningMembership is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership)
+ {
+ stringBuilder.Append(poco.Direction.ToString().ToLower());
+ stringBuilder.Append(' ');
+ }
stringBuilder.Append(' ');
}
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs
index 7e620c73..68093929 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs
@@ -472,7 +472,11 @@ private string ResolveFresh(IElement target, ReferenceSite site, string escapedN
ancestor = QueryOwningContainer(ancestor);
}
- return target.qualifiedName ?? string.Empty;
+ var shortQualifiedName = QueryShortQualifiedName(target);
+
+ return string.IsNullOrWhiteSpace(shortQualifiedName)
+ ? target.qualifiedName ?? string.Empty
+ : shortQualifiedName;
}
///
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
index 495ccce6..541366ea 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
@@ -721,7 +721,15 @@ internal static IFeature QueryEffectiveOwnedMemberFeature(IFeatureMembership mem
{
var direct = membership?.ownedMemberFeature;
- if (membership is IParameterMembership && direct != null)
+ // A NAMED argument must NOT be unwrapped. Both argument forms arrive as a ParameterMembership,
+ // but they need different features: a positional Argument is a bare wrapper around its
+ // ArgumentValue, so the expression inside is what the notation writes, whereas a NamedArgument
+ // (`ownedRelationship += ParameterRedefinition '=' ownedRelationship += ArgumentValue`) IS the
+ // feature to write — unwrapping it hands BuildNamedArgument the value expression, which owns
+ // neither the redefinition nor the value, so it emits a bare `=`. The redefinition is the same
+ // signal IsValidForPositionalArgumentList uses to tell the two apart.
+ if (membership is IParameterMembership && direct != null
+ && !direct.OwnedRelationship.OfType().Any())
{
var featureValue = direct.OwnedRelationship.OfType().FirstOrDefault();
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs
index 136838a3..e5c5d1e2 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs
@@ -140,16 +140,30 @@ internal static bool IsValidForInvertingPart(this IFeature feature, TextualNotat
///
/// Asserts that the is valid for the PositionalArgumentList rule.
/// PositionalArgumentList : Feature = ownedRelationship += ArgumentMember (',' ownedRelationship += ArgumentMember)*
- /// Matches when the cursor is positioned at an
- /// (positional arguments) — the alternative NamedArgumentList uses plain
- /// members.
+ /// The two argument-list alternatives are told apart by whether the member's parameter
+ /// carries a ParameterRedefinition, NOT by the metaclass of the Membership:
+ /// Argument : Feature = ownedRelationship += ArgumentValue — positional, value only.
+ /// NamedArgument : Feature = ownedRelationship += ParameterRedefinition '='
+ /// ownedRelationship += ArgumentValue — named, redefinition first.
///
/// The
/// The active
- /// True if the cursor's current element is an
+ /// True if the cursor is at an argument member whose parameter has no redefinition
+ ///
+ /// The KEBNF declares ArgumentMember : ParameterMembership against
+ /// NamedArgumentMember : FeatureMembership, which reads as though the Membership metaclass
+ /// discriminated the two. It does not: IS an
+ /// , and a named argument arrives as a ParameterMembership too, so a
+ /// test on the Membership alone matches BOTH and routes every named argument down the positional
+ /// path — where BuildArgument looks for an ArgumentValue, finds the
+ /// ParameterRedefinition instead, and emits nothing at all.
+ /// The redefinition is the reliable signal, and it is what the grammar actually keys on: only
+ /// NamedArgument owns one.
+ ///
internal static bool IsValidForPositionalArgumentList(this IFeature feature, TextualNotationWriterContext writerContext)
{
- return QueryCurrentOwnedRelationship(feature, writerContext) is IParameterMembership;
+ return QueryCurrentOwnedRelationship(feature, writerContext) is IParameterMembership parameterMembership
+ && parameterMembership.ownedMemberParameter?.OwnedRelationship.OfType().Any() != true;
}
///