From cc7f4cbe749b6d55552388c4ba375fa995b37bab Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 17:02:26 -0400 Subject: [PATCH 1/6] feat: add SysmlMetadataNode semantic model + Core.Filtering evaluator (Phase 1) - Add SysmlMetadataNode (10th SysmlNode subtype) capturing metadata annotations ({@Type{attr = value;}} / bare @Type;) with resolved type reference and literal attribute values. - Extend AstBuilder to build SysmlMetadataNode from metadataFeature grammar and capture expose bracket-filter raw text (SysmlViewNode.ExposeBracketFilterTexts). - Extend ReferenceResolver to resolve metadata type references into new SysmlEdgeKind.MetadataType edges. - Add DemaConsulting.SysML2Tools.Core.Filtering subsystem: FilterExpression AST with round-trip pretty-printer, FilterExpressionParser (adapts ownedExpression() CST into the Phase 1 construct subset, never throws), and FilterExpressionEvaluator (never throws). - Integrate into GeneralViewLayoutStrategy: standalone filter narrows the rendered scope; parse/eval failures fall back to the existing warning with a reason; expose bracket-filter gets its own distinct unevaluated warning. - Update LayoutWarnings/GeneralViewLayoutStrategyTests/LayoutWarningsTests for the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Filtering/FilterExpression.cs | 173 ++++++++++ .../Filtering/FilterExpressionEvaluator.cs | 177 ++++++++++ .../Filtering/FilterExpressionParser.cs | 322 ++++++++++++++++++ .../Internal/GeneralViewLayoutStrategy.cs | 40 ++- .../Layout/Internal/LayoutWarnings.cs | 43 ++- .../Semantic/Model/AstBuilder.cs | 124 ++++++- .../Semantic/Model/ReferenceResolver.cs | 23 ++ .../Semantic/Model/SysmlEdge.cs | 7 + .../Semantic/Model/SysmlNode.cs | 95 ++++++ .../Layout/GeneralViewLayoutStrategyTests.cs | 41 ++- .../Layout/LayoutWarningsTests.cs | 31 +- 11 files changed, 1043 insertions(+), 33 deletions(-) create mode 100644 src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionEvaluator.cs create mode 100644 src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs new file mode 100644 index 00000000..8a9befa6 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs @@ -0,0 +1,173 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.SysML2Tools.Filtering; + +/// +/// Base type for a view filter [<expr>]; expression's abstract syntax tree, covering +/// exactly the Phase 1 construct subset: classification-test atoms (@Type, @Pkg::Type), +/// boolean connectives (and, or, not, xor, |, &), +/// parenthesization, and (as Type).attribute reads (bare, or compared with ==/!= +/// against a scalar literal). Everything else (istype/hastype/all/arithmetic/ +/// conditional/general feature-chain navigation) is unsupported in Phase 1 and never produces an +/// instance of this type — reports it as a diagnostic instead. +/// +/// +/// Every subtype implements as a canonical pretty-printer that produces valid +/// SysML v2 filter-expression syntax; re-parsing the printed text with +/// yields a semantically-equivalent tree (the round-trip +/// requirement — see docs/design/sysml2-tools-core/filtering.md). +/// +public abstract record FilterExpression +{ + /// + /// Wraps 's pretty-printed text in parentheses when it is a + /// compound expression (boolean connective or comparison) whose precedence could otherwise be + /// misread once embedded as an operand of another expression; atoms (classification test, + /// attribute read, literal) and already-unary never need it. + /// + private protected static string Parenthesize(FilterExpression expression) => + expression is BooleanFilterExpression or ComparisonFilterExpression + ? $"({expression})" + : expression.ToString() ?? string.Empty; +} + +/// +/// A classification-test atom (@Type / @Pkg::Type): true when the candidate element +/// carries a resolved metadata annotation of the referenced type. +/// +/// The raw (possibly qualified) metadata type reference text. +public sealed record ClassificationTestExpression(string TypeName) : FilterExpression +{ + /// + public override string ToString() => $"@{TypeName}"; +} + +/// +/// The boolean connective a node applies. +/// +public enum BooleanConnective +{ + /// Logical conjunction (and / &). + And, + + /// Logical disjunction (or / |). + Or, + + /// Logical exclusive-or (xor). + Xor, +} + +/// +/// A binary boolean connective expression (and/or/xor/|/&). +/// +/// Which boolean connective this node applies. +/// +/// The exact source spelling of the operator ("and", "&", "or", +/// "|", or "xor") — preserved so the pretty-printer reproduces the author's chosen +/// spelling rather than normalizing & to and or | to or. +/// +/// The left operand. +/// The right operand. +public sealed record BooleanFilterExpression( + BooleanConnective Connective, + string OperatorText, + FilterExpression Left, + FilterExpression Right) : FilterExpression +{ + /// + public override string ToString() => $"{Parenthesize(Left)} {OperatorText} {Parenthesize(Right)}"; +} + +/// +/// A unary boolean negation (not X). +/// +/// The negated expression. +public sealed record NotFilterExpression(FilterExpression Operand) : FilterExpression +{ + /// + public override string ToString() => $"not {Parenthesize(Operand)}"; +} + +/// +/// An (as Type).attribute read: evaluates the named literal attribute value captured on the +/// candidate element's Type metadata annotation (see +/// DemaConsulting.SysML2Tools.Semantic.Model.SysmlMetadataNode), or is absent when the +/// candidate carries no such annotation or attribute. +/// +/// The raw (possibly qualified) metadata type reference text. +/// The attribute's simple name. +public sealed record AttributeReadExpression(string TypeName, string AttributeName) : FilterExpression +{ + /// + public override string ToString() => $"(as {TypeName}).{AttributeName}"; +} + +/// +/// The kind of scalar literal a carries. +/// +public enum FilterLiteralKind +{ + /// A boolean literal (true/false). + Boolean, + + /// A numeric literal (integer or real). + Number, + + /// A double-quoted string literal. + String, +} + +/// +/// A scalar literal value (boolean, number, or string) used as the right-hand side of a +/// . +/// +/// Which kind of literal this node holds. +/// The boolean value when is . +/// The numeric value when is . +/// The (unquoted) string value when is . +public sealed record LiteralFilterExpression( + FilterLiteralKind Kind, + bool? BooleanValue = null, + double? NumberValue = null, + string? StringValue = null) : FilterExpression +{ + /// + public override string ToString() => Kind switch + { + FilterLiteralKind.Boolean => (BooleanValue ?? false) ? "true" : "false", + FilterLiteralKind.Number => (NumberValue ?? 0).ToString(System.Globalization.CultureInfo.InvariantCulture), + FilterLiteralKind.String => $"\"{StringValue}\"", + _ => string.Empty, + }; +} + +/// +/// The comparison operator a applies. +/// +public enum ComparisonOperator +{ + /// Equality (==). + Equal, + + /// Inequality (!=). + NotEqual, +} + +/// +/// A comparison of an against a scalar +/// (e.g. (as Safety).isMandatory == true). +/// +/// The attribute read being compared. +/// Which comparison operator applies. +/// The literal being compared against. +public sealed record ComparisonFilterExpression( + AttributeReadExpression Left, + ComparisonOperator Operator, + LiteralFilterExpression Right) : FilterExpression +{ + /// + public override string ToString() => + $"{Left} {(Operator == ComparisonOperator.Equal ? "==" : "!=")} {Right}"; +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionEvaluator.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionEvaluator.cs new file mode 100644 index 00000000..85131317 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionEvaluator.cs @@ -0,0 +1,177 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Filtering; + +/// +/// The outcome of . +/// +/// +/// The subset of the candidate qualified names for which expression evaluated to +/// . +/// +/// Diagnostics produced while evaluating (always empty — evaluation of an +/// already-parsed Phase 1 expression never fails; kept for symmetry with +/// and to leave room for future evaluation-time +/// diagnostics without a breaking API change). +public sealed record FilterEvaluationResult( + IReadOnlyList MatchedQualifiedNames, + IReadOnlyList Diagnostics); + +/// +/// Evaluates a parsed (see ) +/// against a set of candidate elements, narrowing them to those the expression's boolean predicate +/// matches. Never throws. +/// +/// +/// Classification-test atoms (@Type) and (as Type).attribute reads are evaluated +/// against each candidate's directly-owned SysmlMetadataNode children (see +/// AstBuilder's metadataFeature capture): a metadata annotation matches when its +/// resolved target's qualified name equals the filter's +/// type reference (exact match), or when it ends with "::" + TypeName (a bare simple-name +/// reference resolving to a qualified metadata type), or — when the annotation's type reference +/// never resolved (see 's "Unresolved reference" diagnostic) — when +/// its raw text equals the filter's type reference +/// verbatim (a graceful fallback so an otherwise-valid filter still works against a metadata +/// annotation whose defining package failed to resolve for an unrelated reason). +/// An attribute read whose metadata annotation is absent, or whose named attribute was not +/// assigned a literal value, evaluates to "absent": a bare read is treated as +/// , and any comparison against an absent read is treated as +/// regardless of operator — a conservative, documented Phase 1 limitation +/// (see docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md). +/// +public static class FilterExpressionEvaluator +{ + /// + /// Evaluates against each candidate in + /// , returning the subset that matches. + /// + /// The workspace to resolve candidate declarations from. + /// The qualified names of the candidate elements to test. + /// The parsed filter expression to evaluate. + /// The evaluation result (matched subset + diagnostics). + public static FilterEvaluationResult Evaluate( + SysmlWorkspace workspace, + IReadOnlyList candidateQualifiedNames, + FilterExpression expression) + { + ArgumentNullException.ThrowIfNull(workspace); + ArgumentNullException.ThrowIfNull(candidateQualifiedNames); + ArgumentNullException.ThrowIfNull(expression); + + var matched = new List(); + foreach (var qualifiedName in candidateQualifiedNames) + { + if (workspace.Declarations.TryGetValue(qualifiedName, out var node) && + Evaluate(node, expression)) + { + matched.Add(qualifiedName); + } + } + + return new FilterEvaluationResult(matched, Array.Empty()); + } + + /// Evaluates 's boolean value against a single candidate node. + private static bool Evaluate(SysmlNode node, FilterExpression expression) => + expression switch + { + ClassificationTestExpression classificationTest => FindMetadata(node, classificationTest.TypeName) is not null, + NotFilterExpression not => !Evaluate(node, not.Operand), + BooleanFilterExpression boolean => boolean.Connective switch + { + BooleanConnective.And => Evaluate(node, boolean.Left) && Evaluate(node, boolean.Right), + BooleanConnective.Or => Evaluate(node, boolean.Left) || Evaluate(node, boolean.Right), + BooleanConnective.Xor => Evaluate(node, boolean.Left) ^ Evaluate(node, boolean.Right), + _ => false, + }, + AttributeReadExpression attributeRead => ReadAttribute(node, attributeRead) is { Kind: MetadataAttributeValueKind.Boolean } value + && (value.BooleanValue ?? false), + ComparisonFilterExpression comparison => EvaluateComparison(node, comparison), + _ => false, + }; + + /// Evaluates a comparison expression: absent attribute reads always evaluate to false. + private static bool EvaluateComparison(SysmlNode node, ComparisonFilterExpression comparison) + { + var value = ReadAttribute(node, comparison.Left); + if (value is null) + { + return false; + } + + var equal = ValuesEqual(value, comparison.Right); + return comparison.Operator == ComparisonOperator.Equal ? equal : !equal; + } + + /// Compares a captured literal attribute value against a filter-expression literal. + private static bool ValuesEqual(MetadataAttributeValue value, LiteralFilterExpression literal) => + (value.Kind, literal.Kind) switch + { + (MetadataAttributeValueKind.Boolean, FilterLiteralKind.Boolean) => value.BooleanValue == literal.BooleanValue, + (MetadataAttributeValueKind.Number, FilterLiteralKind.Number) => NumbersEqual(value.NumberValue, literal.NumberValue), + (MetadataAttributeValueKind.String, FilterLiteralKind.String) => value.StringValue == literal.StringValue, + _ => false, + }; + + /// + /// Compares two nullable numeric values for equality using a small relative/absolute tolerance, + /// avoiding a direct floating-point equality check (both literal integers and reals share the + /// / + /// representation, so an exact-integer comparison like 4 == 4 must still succeed). + /// + private static bool NumbersEqual(double? left, double? right) + { + if (left is null || right is null) + { + return false; + } + + return Math.Abs(left.Value - right.Value) <= 1e-9 * Math.Max(1.0, Math.Max(Math.Abs(left.Value), Math.Abs(right.Value))); + } + + /// Reads the named literal attribute value off the candidate's matching metadata annotation, or null when absent. + private static MetadataAttributeValue? ReadAttribute(SysmlNode node, AttributeReadExpression attributeRead) + { + var metadata = FindMetadata(node, attributeRead.TypeName); + return metadata?.Attributes.FirstOrDefault(a => a.Name == attributeRead.AttributeName); + } + + /// + /// Finds the first directly-owned child of + /// whose annotating type matches (see class remarks for the exact + /// matching rules), or when none match. + /// + private static SysmlMetadataNode? FindMetadata(SysmlNode node, string typeName) + { + foreach (var child in node.Children) + { + if (child is not SysmlMetadataNode metadata) + { + continue; + } + + var resolvedTarget = metadata.ResolvedEdges + .FirstOrDefault(e => e.Kind == SysmlEdgeKind.MetadataType) + ?.TargetQualifiedName; + + if (resolvedTarget is not null && + (resolvedTarget == typeName || resolvedTarget.EndsWith("::" + typeName, StringComparison.Ordinal))) + { + return metadata; + } + + if (resolvedTarget is null && metadata.TypeReference == typeName) + { + return metadata; + } + } + + return null; + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs new file mode 100644 index 00000000..3c325290 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs @@ -0,0 +1,322 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using Antlr4.Runtime; +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Parser.Antlr; + +namespace DemaConsulting.SysML2Tools.Filtering; + +/// +/// The outcome of : either a successfully-built +/// tree, or a set of diagnostics explaining why parsing failed +/// (a syntax error, or a construct outside the Phase 1 subset). +/// +/// +/// The parsed expression tree, or when the raw text could not be parsed as +/// valid syntax, or contained a construct outside the Phase 1 subset (see +/// for the reason). +/// +/// +/// Diagnostics produced while parsing. Empty when is non-null and no +/// warnings apply. +/// +public sealed record FilterParseResult(FilterExpression? Expression, IReadOnlyList Diagnostics); + +/// +/// Adapts the ANTLR-generated 's ownedExpression() parse of a raw +/// filter-expression fragment (e.g. ) +/// into a tree, restricted to the Phase 1 construct subset: +/// classification-test atoms, boolean connectives, parenthesization, and +/// (as Type).attribute reads (bare, or compared with ==/!= against a scalar +/// literal). This class never throws — any syntax error or unsupported construct is reported as a +/// in the returned instead. +/// +public static class FilterExpressionParser +{ + /// + /// Virtual file path used when reporting diagnostics for a standalone filter-expression parse + /// (there is no real source file to name, since the expression text is a fragment already + /// extracted from its enclosing view by AstBuilder). + /// + private const string VirtualFilePath = "[filter-expression]"; + + /// + /// Parses into a tree. + /// + /// The raw filter-expression source text to parse. + /// + /// A whose is + /// non-null only when the entire expression parsed as valid syntax within the Phase 1 + /// construct subset. + /// + public static FilterParseResult Parse(string expressionText) + { + ArgumentNullException.ThrowIfNull(expressionText); + + var diagnostics = new List(); + + SysMLv2Parser.OwnedExpressionContext cst; + try + { + var listener = new CollectingErrorListener(diagnostics); + var inputStream = new AntlrInputStream(expressionText); + + var lexer = new SysMLv2Lexer(inputStream); + lexer.RemoveErrorListeners(); + lexer.AddErrorListener(listener); + + var tokenStream = new CommonTokenStream(lexer); + + var parser = new SysMLv2Parser(tokenStream); + parser.RemoveErrorListeners(); + parser.AddErrorListener(listener); + + cst = parser.ownedExpression(); + } + catch (RecognitionException ex) + { + diagnostics.Add(Diagnostic($"Filter expression syntax error: {ex.Message}")); + return new FilterParseResult(null, diagnostics); + } + + if (diagnostics.Count > 0) + { + // Syntax errors already reported by the ANTLR error listener above. + return new FilterParseResult(null, diagnostics); + } + + var expression = TryBuild(cst, diagnostics); + return new FilterParseResult(expression, diagnostics); + } + + /// Builds a for the virtual filter-expression file. + private static SysmlDiagnostic Diagnostic(string message) => + new(VirtualFilePath, 1, 0, DiagnosticSeverity.Error, message); + + /// + /// Recursively converts an ownedExpression CST node into a , + /// restricted to the Phase 1 construct subset. Returns and appends an + /// "unsupported construct" diagnostic when the node (or one of its descendants) uses a + /// construct outside that subset. + /// + private static FilterExpression? TryBuild( + SysMLv2Parser.OwnedExpressionContext context, List diagnostics) + { + // Classification test: (AT_SIGN|AT_AT) typeReference — the prefix form only (no left + // operand). The postfix forms (`x @ Type`, `x istype Type`, `x hastype Type`) are + // unsupported in Phase 1 (general feature-chain navigation on the left-hand side). + if (context.typeReference() is { } typeRef && context.ownedExpression().Length == 0 && + (context.AT_SIGN() is not null || context.AT_AT() is not null)) + { + return new ClassificationTestExpression(typeRef.GetText()); + } + + if (context.ISTYPE() is not null || context.HASTYPE() is not null || context.ALL() is not null) + { + return Unsupported(context, diagnostics); + } + + // Boolean connectives: and/or/xor (keyword and symbol spellings) and unary not. + var operands = context.ownedExpression(); + if (context.AND() is not null && operands.Length == 2) + { + return BuildBoolean(BooleanConnective.And, "and", operands, diagnostics); + } + + if (context.OR() is not null && operands.Length == 2) + { + return BuildBoolean(BooleanConnective.Or, "or", operands, diagnostics); + } + + if (context.XOR() is not null && operands.Length == 2) + { + return BuildBoolean(BooleanConnective.Xor, "xor", operands, diagnostics); + } + + if (context.AMP() is not null && operands.Length == 2) + { + return BuildBoolean(BooleanConnective.And, "&", operands, diagnostics); + } + + if (context.PIPE() is not null && operands.Length == 2) + { + return BuildBoolean(BooleanConnective.Or, "|", operands, diagnostics); + } + + if (context.NOT() is not null && operands.Length == 1) + { + var operand = TryBuild(operands[0], diagnostics); + return operand is null ? null : new NotFilterExpression(operand); + } + + // Equality comparison: (as Type).attribute == literal / != literal + if ((context.EQ_EQ() is not null || context.BANG_EQ() is not null) && operands.Length == 2) + { + return BuildComparison(context, operands, diagnostics); + } + + // (as Type).attribute — a DOT read on a metadata-cast base expression. + if (context.DOT() is not null && operands.Length == 1 && context.qualifiedName().Length > 0) + { + return BuildAttributeRead(context, diagnostics); + } + + // Parenthesized sub-expression with no other operator present: baseExpression covers the + // `(as Type)` cast (handled above via DOT) and plain `( ownedExpression )` grouping — + // ANTLR's flattened ownedExpression rule represents grouping via baseExpression's + // `LPAREN sequenceExpressionList? RPAREN` alternative, which is only reachable when this + // node has no other operator tokens and a single nested ownedExpression. + if (operands.Length == 0 && context.baseExpression() is { } baseExpr) + { + return TryBuildBaseExpression(baseExpr, diagnostics); + } + + return Unsupported(context, diagnostics); + } + + /// Builds a , propagating operand failures. + private static FilterExpression? BuildBoolean( + BooleanConnective connective, + string operatorText, + SysMLv2Parser.OwnedExpressionContext[] operands, + List diagnostics) + { + var left = TryBuild(operands[0], diagnostics); + var right = TryBuild(operands[1], diagnostics); + return left is null || right is null ? null : new BooleanFilterExpression(connective, operatorText, left, right); + } + + /// + /// Builds a from an ==/!= node. Only + /// supported when the left operand is an (as Type).attribute read and the right operand + /// is a scalar literal — any other shape is reported as unsupported. + /// + private static FilterExpression? BuildComparison( + SysMLv2Parser.OwnedExpressionContext context, + SysMLv2Parser.OwnedExpressionContext[] operands, + List diagnostics) + { + var left = TryBuild(operands[0], diagnostics); + if (left is not AttributeReadExpression attributeRead) + { + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: comparison left-hand side must be an '(as Type).attribute' read, found '{operands[0].GetText()}'.")); + return null; + } + + var right = TryBuildLiteral(operands[1], diagnostics); + if (right is null) + { + return null; + } + + var op = context.EQ_EQ() is not null ? ComparisonOperator.Equal : ComparisonOperator.NotEqual; + return new ComparisonFilterExpression(attributeRead, op, right); + } + + /// Builds an from a (as Type).attribute DOT node. + private static FilterExpression? BuildAttributeRead( + SysMLv2Parser.OwnedExpressionContext context, List diagnostics) + { + var baseExpr = context.ownedExpression(0).baseExpression(); + if (baseExpr?.AS() is null || baseExpr.typeReference() is not { } typeRef) + { + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: '.' navigation is only supported on an '(as Type)' cast, found '{context.GetText()}'.")); + return null; + } + + var attributeName = context.qualifiedName(0).GetText(); + return new AttributeReadExpression(typeRef.GetText(), attributeName); + } + + /// Handles a parenthesized-grouping baseExpression with a single nested expression. + private static FilterExpression? TryBuildBaseExpression( + SysMLv2Parser.BaseExpressionContext baseExpr, List diagnostics) + { + var inner = baseExpr.sequenceExpressionList()?.ownedExpression(); + if (baseExpr.LPAREN() is not null && baseExpr.AS() is null && inner is { Length: 1 }) + { + return TryBuild(inner[0], diagnostics); + } + + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: '{baseExpr.GetText()}'.")); + return null; + } + + /// Builds a from a literal-only ownedExpression node. + private static LiteralFilterExpression? TryBuildLiteral( + SysMLv2Parser.OwnedExpressionContext context, List diagnostics) + { + var literal = context.baseExpression()?.literalExpression(); + if (literal is null) + { + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: comparison right-hand side must be a scalar literal, found '{context.GetText()}'.")); + return null; + } + + if (literal.literalBoolean() is { } boolLiteral) + { + return new LiteralFilterExpression(FilterLiteralKind.Boolean, BooleanValue: boolLiteral.TRUE() is not null); + } + + if (literal.literalString() is { } stringLiteral) + { + var text = stringLiteral.GetText(); + var unquoted = text.Length >= 2 ? text[1..^1] : text; + return new LiteralFilterExpression(FilterLiteralKind.String, StringValue: unquoted); + } + + if (literal.literalInteger() is { } intLiteral && + double.TryParse(intLiteral.GetText(), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var iv)) + { + return new LiteralFilterExpression(FilterLiteralKind.Number, NumberValue: iv); + } + + if (literal.literalReal() is { } realLiteral && + double.TryParse(realLiteral.GetText(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var rv)) + { + return new LiteralFilterExpression(FilterLiteralKind.Number, NumberValue: rv); + } + + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: comparison right-hand side must be a scalar literal, found '{context.GetText()}'.")); + return null; + } + + /// Reports an "unsupported construct" diagnostic for a node outside the Phase 1 subset. + private static FilterExpression? Unsupported( + SysMLv2Parser.OwnedExpressionContext context, List diagnostics) + { + diagnostics.Add(Diagnostic($"Unsupported filter construct: '{context.GetText()}'.")); + return null; + } + + /// + /// ANTLR error listener that appends syntax errors to a diagnostics list rather than throwing + /// or writing to the console, so never crashes on malformed input. + /// + private sealed class CollectingErrorListener(List diagnostics) : + IAntlrErrorListener, + IAntlrErrorListener + { + void IAntlrErrorListener.SyntaxError( + TextWriter output, IRecognizer recognizer, IToken offendingSymbol, + int line, int charPositionInLine, string msg, RecognitionException e) => + Append(line, charPositionInLine, msg); + + void IAntlrErrorListener.SyntaxError( + TextWriter output, IRecognizer recognizer, int offendingSymbol, + int line, int charPositionInLine, string msg, RecognitionException e) => + Append(line, charPositionInLine, msg); + + private void Append(int line, int column, string msg) => + diagnostics.Add(new SysmlDiagnostic(VirtualFilePath, line, column, DiagnosticSeverity.Error, $"Filter expression syntax error: {msg}")); + } +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index bdf58c81..66899e43 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -5,6 +5,7 @@ using DemaConsulting.Rendering; using DemaConsulting.Rendering.Abstractions; using DemaConsulting.Rendering.Layout; +using DemaConsulting.SysML2Tools.Filtering; using DemaConsulting.SysML2Tools.Rendering; using DemaConsulting.SysML2Tools.Rendering.Internal; using DemaConsulting.SysML2Tools.Semantic; @@ -134,6 +135,33 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return new LayoutTree(200.0, 100.0, []); } + // Apply the view's standalone `filter [];` statement (SysmlViewNode.FilterExpressionText), + // narrowing `defs` to the subset the Phase 1 expression subset matches. A parse/evaluation + // failure (syntax error or a construct outside the Phase 1 subset — see + // FilterExpressionParser) falls back to rendering the unfiltered resolved scope, surfaced + // via a warning rather than silently dropping the filter or crashing. + string? filterFailureReason = null; + var filterExpressionText = context.ViewNode?.FilterExpressionText; + if (filterExpressionText is { Length: > 0 }) + { + var parseResult = FilterExpressionParser.Parse(filterExpressionText); + if (parseResult.Expression is { } expression) + { + var matched = FilterExpressionEvaluator.Evaluate( + context.Workspace, defs.Select(d => d.QualifiedName).ToList(), expression).MatchedQualifiedNames; + var matchedSet = new HashSet(matched, StringComparer.Ordinal); + defs = defs.Where(d => matchedSet.Contains(d.QualifiedName)).ToList(); + if (defs.Count == 0) + { + return new LayoutTree(200.0, 100.0, []); + } + } + else + { + filterFailureReason = parseResult.Diagnostics.FirstOrDefault()?.Message; + } + } + // Group definitions by their owning package (prefix before the last "::"). var groups = GroupByPackage(defs); @@ -157,11 +185,13 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) // tree aligns with graph.Nodes by index. var placed = truncated.Count == 0 ? tree : DecorateTruncatedFolders(tree, graph, truncated, theme); - // A `filter [];` statement is parsed (SysmlViewNode.FilterExpressionText) but not - // yet evaluated — full expression evaluation is deferred future work (see ROADMAP.md). - // Surface this to the caller through the standard layout-warnings channel rather than - // silently rendering a diagram the user may believe is filtered. - var warnings = LayoutWarnings.ForUnevaluatedFilter(context.ViewName, context.ViewNode?.FilterExpressionText); + // Surface any filter-evaluation failure, plus a distinct warning for a still-unevaluated + // `expose ::**[]` bracket filter (Phase 1 captures its raw text only — see + // SysmlViewNode.ExposeBracketFilterTexts — full bracket-filter evaluation is deferred + // future work, see ROADMAP.md), through the standard layout-warnings channel. + var warnings = LayoutWarnings.ForUnevaluatedFilter(context.ViewName, filterFailureReason is null ? null : filterExpressionText, filterFailureReason) + .Concat(LayoutWarnings.ForUnevaluatedExposeBracketFilter(context.ViewName, context.ViewNode?.ExposeBracketFilterTexts ?? [])) + .ToList(); return warnings.Count == 0 ? placed : placed with { Warnings = warnings }; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs index fc0782e8..798776c9 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs @@ -38,26 +38,59 @@ public static IReadOnlyList ForCrossings(string viewName, int crossings) /// /// Returns a single-element warning list stating that a view's filter [<expr>]; - /// statement was parsed but not evaluated, or an empty list when the view declares no filter - /// expression. + /// statement could not be evaluated (a parse error or an unsupported Phase 1 construct), or an + /// empty list when the view declares no filter expression. /// /// Name of the view being laid out. /// /// The view's raw filter expression source text, or when the view /// declares no filter member. /// + /// + /// A short human-readable explanation of why evaluation could not proceed (e.g. the first + /// parse/evaluation diagnostic message), or to omit the reason clause. + /// /// The warning messages for the view. - public static IReadOnlyList ForUnevaluatedFilter(string viewName, string? filterExpressionText) + public static IReadOnlyList ForUnevaluatedFilter( + string viewName, string? filterExpressionText, string? reason = null) { if (filterExpressionText is null) { return []; } + var suffix = reason is { Length: > 0 } ? $" ({reason})" : string.Empty; + return + [ + $"View '{viewName}' declares a filter expression that could not be evaluated{suffix}; " + + "all elements in the resolved scope are rendered unfiltered.", + ]; + } + + /// + /// Returns a single-element warning list stating that a view's expose <path>::**[<expr>] + /// bracket-filter expression(s) were parsed but not yet evaluated (Phase 1 captures raw text + /// only — see SysmlViewNode.ExposeBracketFilterTexts), or an empty list when the view + /// declares no bracket-filter expose members. + /// + /// Name of the view being laid out. + /// The view's raw bracket-filter expression source texts. + /// The warning messages for the view. + public static IReadOnlyList ForUnevaluatedExposeBracketFilter( + string viewName, IReadOnlyList bracketFilterTexts) + { + if (bracketFilterTexts.Count == 0) + { + return []; + } + + var plural = bracketFilterTexts.Count == 1 ? "expression" : "expressions"; + var verb = bracketFilterTexts.Count == 1 ? "is" : "are"; return [ - $"View '{viewName}' declares a filter expression, which is parsed but not yet " + - "evaluated; all elements in the resolved scope are rendered unfiltered.", + $"View '{viewName}' declares {bracketFilterTexts.Count} expose bracket-filter {plural} " + + $"('::**[...]'), which {verb} parsed but not yet evaluated; the bracket filter has no " + + "effect on the rendered scope.", ]; } } diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index a95be87a..fdf86d49 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // Licensed under the MIT License. +using System.Globalization; using DemaConsulting.SysML2Tools.Parser.Antlr; namespace DemaConsulting.SysML2Tools.Semantic.Model; @@ -121,11 +122,90 @@ private string QualifyName(string name) }; } - // textualRepresentation / metadataFeature: out of scope for this unit, preserves the - // existing drop behavior (falls through to the default visitor, which returns null). + // metadataFeature: build a SysmlMetadataNode capturing the annotating type reference and + // any literal attribute values assigned in its body. textualRepresentation remains out of + // scope for this unit, preserving the existing drop behavior. + if (context.metadataFeature() is { } metadataFeature) + { + return BuildMetadataNode(metadataFeature); + } + return base.VisitAnnotatingElement(context); } + /// + /// Builds a from a metadataFeature parse (the + /// {@Type{attr = value;}} / bare @Type; forms), capturing the annotating + /// type's raw reference text and any literal (boolean/number/string) attribute values + /// assigned directly in its body. Non-literal value expressions are captured as raw text + /// with — never evaluated, per the + /// Phase 1 construct boundary (see ROADMAP.md). + /// + private static SysmlMetadataNode BuildMetadataNode(SysMLv2Parser.MetadataFeatureContext context) + { + var typeReference = context.metadataFeatureDeclaration()?.ownedFeatureTyping()?.GetText() ?? string.Empty; + + var attributes = new List(); + foreach (var element in context.metadataBody()?.metadataBodyElement() ?? []) + { + var feature = element.metadataBodyFeatureMember()?.metadataBodyFeature(); + var name = feature?.ownedRedefinition()?.GetText(); + var valueExpr = feature?.valuePart()?.featureValue()?.ownedExpression(); + if (string.IsNullOrEmpty(name) || valueExpr is null) + { + continue; + } + + attributes.Add(BuildMetadataAttributeValue(name, valueExpr)); + } + + return new SysmlMetadataNode + { + TypeReference = typeReference, + Attributes = attributes, + }; + } + + /// + /// Classifies a metadata attribute's assigned value expression as a scalar literal + /// (boolean/number/string) when possible, or as + /// (raw text preserved, never + /// evaluated) for any other value expression shape. + /// + private static MetadataAttributeValue BuildMetadataAttributeValue( + string name, SysMLv2Parser.OwnedExpressionContext expr) + { + var raw = expr.GetText(); + var literal = expr.baseExpression()?.literalExpression(); + + if (literal?.literalBoolean() is { } boolLiteral) + { + return new MetadataAttributeValue( + name, MetadataAttributeValueKind.Boolean, raw, BooleanValue: boolLiteral.TRUE() is not null); + } + + if (literal?.literalString() is { } stringLiteral) + { + var text = stringLiteral.GetText(); + var unquoted = text.Length >= 2 ? text[1..^1] : text; + return new MetadataAttributeValue(name, MetadataAttributeValueKind.String, raw, StringValue: unquoted); + } + + if (literal?.literalInteger() is { } integerLiteral && + double.TryParse(integerLiteral.GetText(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var iv)) + { + return new MetadataAttributeValue(name, MetadataAttributeValueKind.Number, raw, NumberValue: iv); + } + + if (literal?.literalReal() is { } realLiteral && + double.TryParse(realLiteral.GetText(), NumberStyles.Float, CultureInfo.InvariantCulture, out var rv)) + { + return new MetadataAttributeValue(name, MetadataAttributeValueKind.Number, raw, NumberValue: rv); + } + + return new MetadataAttributeValue(name, MetadataAttributeValueKind.Unsupported, raw); + } + /// /// Strips the /*///* opening delimiter and trailing */ closing /// delimiter from a REGULAR_COMMENT token's text, preserving all interior @@ -922,7 +1002,7 @@ private static IReadOnlyList ExtractSubsettingTargetNames(SysMLv2Parser. var bodyItems = context.viewBody()?.viewBodyItem() ?? []; var (renderTargetName, filterExpressionText) = ExtractViewRenderAndFilter(bodyItems); - var exposedNames = ExtractExposedNames(bodyItems); + var (exposedNames, exposeBracketFilterTexts) = ExtractExposedNames(bodyItems); return new SysmlViewNode { @@ -931,6 +1011,7 @@ private static IReadOnlyList ExtractSubsettingTargetNames(SysMLv2Parser. RenderTargetName = renderTargetName, ExposedNames = exposedNames, FilterExpressionText = filterExpressionText, + ExposeBracketFilterTexts = exposeBracketFilterTexts, }; } @@ -1011,10 +1092,11 @@ private static (string? RenderTargetName, string? FilterExpressionText) ExtractV /// view usage's body, in source order, reusing — /// the same namespace/membership-import shape import statements use. /// - private static IReadOnlyList ExtractExposedNames( + private static (IReadOnlyList ExposedNames, IReadOnlyList BracketFilterTexts) ExtractExposedNames( IEnumerable bodyItems) { var names = new List(); + var bracketFilterTexts = new List(); foreach (var item in bodyItems) { var expose = item.expose(); @@ -1023,16 +1105,21 @@ private static IReadOnlyList ExtractExposedNames( continue; } - var (qn, _) = ExtractImportTarget( + var (qn, _, bracketFilterText) = ExtractImportTarget( expose.namespaceExpose()?.namespaceImport(), expose.membershipExpose()?.membershipImport()); if (qn is { Length: > 0 }) { names.Add(qn); } + + if (bracketFilterText is { Length: > 0 }) + { + bracketFilterTexts.Add(bracketFilterText); + } } - return names; + return (names, bracketFilterTexts); } /// @@ -1065,7 +1152,7 @@ private static IReadOnlyList ExtractExposedNames( return null; } - var (qn, isWildcard) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); + var (qn, isWildcard, bracketFilterText) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); if (qn is null) { return null; @@ -1076,6 +1163,7 @@ private static IReadOnlyList ExtractExposedNames( ImportedNamespace = qn, ImportedNames = [qn], IsWildcard = isWildcard, + BracketFilterExpressionText = bracketFilterText, }; } @@ -1095,7 +1183,7 @@ private static IReadOnlyList ExtractExposedNames( /// The extracted qualified/dotted name text (or null when neither alternative yielded /// text) and whether the import/expose is a wildcard. /// - private static (string? QualifiedName, bool IsWildcard) ExtractImportTarget( + private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExpressionText) ExtractImportTarget( SysMLv2Parser.NamespaceImportContext? namespaceImport, SysMLv2Parser.MembershipImportContext? membershipImport) { @@ -1105,15 +1193,21 @@ private static (string? QualifiedName, bool IsWildcard) ExtractImportTarget( var qn = namespaceImport.qualifiedName()?.GetText(); if (qn is { Length: > 0 }) { - return (qn, true); + return (qn, true, null); } // Bracketed-filter form: qualifiedName::**[filterExpr] — the dominant expose form in // the real OMG corpus. The grammar nests the qualified name two levels deeper here: // namespaceImport -> filterPackage -> filterPackageImportDeclaration -> (membershipImport // | namespaceImportDirect). Descend through that chain rather than only checking the - // direct qualifiedName() child (which is null for this alternative). - var filterDecl = namespaceImport.filterPackage()?.filterPackageImportDeclaration(); + // direct qualifiedName() child (which is null for this alternative). The bracket + // expression text itself is captured raw only (Phase 1 does not evaluate it — see + // SysmlViewNode.ExposeBracketFilterTexts) from the filterPackage's first + // filterPackageMember (multiple bracket filters chained on one path are extremely + // rare; the first is representative for the "unevaluated" warning). + var filterPackage = namespaceImport.filterPackage(); + var bracketFilterText = filterPackage?.filterPackageMember()?.FirstOrDefault()?.ownedExpression()?.GetText(); + var filterDecl = filterPackage?.filterPackageImportDeclaration(); if (filterDecl is not null) { var filterMembershipImport = filterDecl.membershipImport(); @@ -1122,7 +1216,7 @@ private static (string? QualifiedName, bool IsWildcard) ExtractImportTarget( var filterQn = filterMembershipImport.qualifiedName()?.GetText(); if (filterQn is { Length: > 0 }) { - return (filterQn, filterMembershipImport.STAR_STAR() is not null); + return (filterQn, filterMembershipImport.STAR_STAR() is not null, bracketFilterText); } } @@ -1132,7 +1226,7 @@ private static (string? QualifiedName, bool IsWildcard) ExtractImportTarget( var directQn = namespaceImportDirect.qualifiedName()?.GetText(); if (directQn is { Length: > 0 }) { - return (directQn, true); + return (directQn, true, bracketFilterText); } } } @@ -1145,11 +1239,11 @@ private static (string? QualifiedName, bool IsWildcard) ExtractImportTarget( var qn = membershipImport.qualifiedName()?.GetText(); if (qn is { Length: > 0 }) { - return (qn, membershipImport.STAR_STAR() is not null); + return (qn, membershipImport.STAR_STAR() is not null, null); } } - return (null, false); + return (null, false, null); } /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs index bebc4b24..43aef57c 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs @@ -770,6 +770,29 @@ private void ResolveNode( } } + // Metadata annotations (SysmlMetadataNode, a Children entry of the element it annotates) + // resolve their annotating type reference (e.g. "Safety" / "Pkg::Safety") the same way + // feature typing does. The resulting edge's source is this metadata node's own + // (usually-null) QualifiedName — callers (Core.Filtering's FilterExpressionEvaluator) + // read the resolved target off node.ResolvedEdges directly rather than by source-name + // lookup, since a metadata annotation is always addressed by its owning element, not by + // its own identity. + if (node is SysmlMetadataNode { TypeReference.Length: > 0 } metadata) + { + if (TryResolve(metadata.TypeReference, namespaceStack, imports, out var resolvedType)) + { + nodeEdges.Add(new SysmlEdge(node.QualifiedName, resolvedType, SysmlEdgeKind.MetadataType)); + } + else if (resolvedInFile.Add(metadata.TypeReference)) + { + _diagnostics.Add(new SysmlDiagnostic( + filePath, + 0, 0, + DiagnosticSeverity.Warning, + $"Unresolved reference: '{metadata.TypeReference}'")); + } + } + if (nodeEdges.Count > 0) { node.ResolvedEdges = nodeEdges; diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs index fdd85e82..b84ffa81 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs @@ -84,6 +84,13 @@ public enum SysmlEdgeKind /// resolved redefined-feature reference. /// Redefinition, + + /// + /// A metadata annotation's type reference ( / + /// @Type / {@Type{...}}), from the annotation to the resolved metadata + /// def declaration it references. + /// + MetadataType, } /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs index 1283fdf9..5db24cea 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs @@ -31,6 +31,7 @@ namespace DemaConsulting.SysML2Tools.Semantic.Model; [JsonDerivedType(typeof(SysmlConnectionNode), "connection")] [JsonDerivedType(typeof(SysmlTransitionNode), "transition")] [JsonDerivedType(typeof(SysmlSatisfyNode), "satisfy")] +[JsonDerivedType(typeof(SysmlMetadataNode), "metadata")] public abstract class SysmlNode { /// @@ -171,8 +172,91 @@ public sealed class SysmlImportNode : SysmlNode /// Gets a value indicating whether this is a wildcard import (::*). /// public bool IsWildcard { get; init; } + + /// + /// Gets the raw source text of this import's bracketed filter expression (from + /// expose <path>::**[<expr>]'s filterPackageMember().ownedExpression().GetText()), + /// or when the import declares no bracket filter. Captured verbatim + /// only — mirroring — no expression tree is + /// built and no evaluation is performed in Phase 1; a non-null value causes + /// GeneralViewLayoutStrategy to emit an "unevaluated" warning. Full bracket-filter + /// evaluation is deferred future work — see the project ROADMAP. + /// + public string? BracketFilterExpressionText { get; init; } +} + +/// +/// AST node representing an applied metadata annotation ({@Type{attr = value;}} or the +/// bare @Type;/@Type{} forms), captured from a metadataFeature nested in +/// an owning element's body. +/// +/// +/// Inherited from SysmlNode: Name, QualifiedName, Children, SupertypeNames, ImportedNames, +/// VerifiedRequirementNames, ResolvedEdges, Annotations. This node is attached as a +/// entry of the element it annotates (its lexically enclosing +/// definition/feature), not as an entry — unlike +/// comment/documentation, a metadata annotation is a first-class semantic reference (resolved +/// by ) rather than free-text documentation. +/// +public sealed class SysmlMetadataNode : SysmlNode +{ + /// + /// Gets the raw reference text of the annotating metadata type (e.g. "Safety" or + /// "Pkg::Safety"), from metadataFeatureDeclaration().ownedFeatureTyping(). + /// Resolved by into a + /// edge, or an unresolved-reference diagnostic when it does not resolve. + /// + public string TypeReference { get; init; } = string.Empty; + + /// + /// Gets the literal attribute values assigned in this annotation's body (e.g. + /// isMandatory = true;), in source order. Only scalar boolean/number/string literal + /// values are captured in Phase 1 (see ); non-literal + /// value expressions are recorded with + /// and their raw text preserved, never evaluated. + /// + public IReadOnlyList Attributes { get; init; } = Array.Empty(); } +/// +/// Classifies the kind of literal value captured for a . +/// +public enum MetadataAttributeValueKind +{ + /// A boolean literal (true/false). + Boolean, + + /// A numeric literal (integer or real). + Number, + + /// A double-quoted string literal. + String, + + /// + /// A value expression that is not a scalar literal (e.g. a feature reference, arithmetic + /// expression, or constructor call) — captured as raw text only, never evaluated. + /// + Unsupported, +} + +/// +/// A single literal attribute value assigned within a 's body +/// (e.g. isMandatory = true;). +/// +/// The attribute's simple name (e.g. "isMandatory"). +/// The kind of literal value captured. +/// The raw source text of the value expression (e.g. "true"). +/// The parsed boolean value when is . +/// The parsed numeric value when is . +/// The parsed (unquoted) string value when is . +public sealed record MetadataAttributeValue( + string Name, + MetadataAttributeValueKind Kind, + string RawText, + bool? BooleanValue = null, + double? NumberValue = null, + string? StringValue = null); + /// /// AST node representing a view definition or view usage. /// @@ -215,6 +299,17 @@ public sealed class SysmlViewNode : SysmlNode /// deferred future work — see the project ROADMAP. /// public string? FilterExpressionText { get; init; } + + /// + /// Gets the raw source text of every bracketed expose <path>::**[<expr>] + /// filter expression found among this view's expose members, in source order. + /// Captured verbatim only (from filterPackageMember().ownedExpression().GetText()) — + /// no expression tree is built and no evaluation is performed in Phase 1; a non-empty list + /// causes GeneralViewLayoutStrategy to emit an "unevaluated" warning distinct from + /// 's. Full bracket-filter evaluation is deferred future + /// work — see the project ROADMAP. + /// + public IReadOnlyList ExposeBracketFilterTexts { get; init; } = Array.Empty(); } /// diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index cf75363e..51eb1d16 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -1142,22 +1142,23 @@ public void GeneralViewLayoutStrategy_BuildLayout_ExposedUsage_ResolvesThroughTy } /// - /// A view whose FilterExpressionText is non-null emits the "parsed but not yet - /// evaluated" diagnostic through , while still rendering - /// the (unfiltered) resolved scope — per the binding decision to defer filter expression - /// evaluation to a future roadmap item. + /// A view whose FilterExpressionText uses a construct outside the Phase 1 subset + /// emits a "could not be evaluated" diagnostic through , + /// while still rendering the (unfiltered) resolved scope — the documented fallback + /// behavior for a filter expression that fails to parse/evaluate (see ROADMAP.md). /// [Fact] public void GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning() { - // Arrange: a view declaring a filter expression + // Arrange: a view declaring a filter expression outside the Phase 1 construct subset + // (arithmetic addition, which has no corresponding FilterExpression node). var strategy = new GeneralViewLayoutStrategy(); var workspace = BuildScopingWorkspace(); var viewNode = new SysmlViewNode { Name = "V", QualifiedName = "Root::V", - FilterExpressionText = "@SysML::PartUsage" + FilterExpressionText = "1 + 2" }; var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1167,12 +1168,38 @@ public void GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsN // Assert: a warning about the unevaluated filter is present, and the resolved (unfiltered) // scope — here, the full workspace, since no expose statement was declared — still renders. - Assert.Contains(layout.Warnings, w => w.Contains("filter expression") && w.Contains("not yet evaluated")); + Assert.Contains(layout.Warnings, w => w.Contains("filter expression") && w.Contains("could not be evaluated")); var labels = CollectBoxes(layout.Nodes).Select(b => b.Label).ToList(); Assert.Contains("A", labels); Assert.Contains("B", labels); } + /// + /// A view whose FilterExpressionText is a Phase 1 classification-test expression + /// that matches no candidate's metadata annotations narrows the rendered scope to nothing, + /// confirming standalone filter evaluation actually applies (as opposed to the + /// legacy "parsed but not evaluated" behavior). + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_FilterExpressionMatchesNothing_RendersEmpty() + { + var strategy = new GeneralViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + FilterExpressionText = "@NoSuchMetadataType" + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Warnings); + Assert.Empty(CollectBoxes(layout.Nodes)); + } + /// /// A view with no expose statement (a null , e.g. /// the --auto synthesized view) renders identically to the pre-scoping-change diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs index 39f31a70..ecae1979 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs @@ -48,7 +48,7 @@ public void ForUnevaluatedFilter_NullText_ReturnsEmpty() /// /// A non-null filter expression text produces a single warning naming the view and stating - /// that the filter expression is parsed but not yet evaluated. + /// that the filter expression could not be evaluated. /// [Fact] public void ForUnevaluatedFilter_NonNullText_ReturnsNotYetEvaluatedWarning() @@ -58,6 +58,35 @@ public void ForUnevaluatedFilter_NonNullText_ReturnsNotYetEvaluatedWarning() var message = Assert.Single(warnings); Assert.Contains("MyView", message); Assert.Contains("filter expression", message); + Assert.Contains("could not be evaluated", message); + } + + /// A reason, when supplied, is included in the warning message. + [Fact] + public void ForUnevaluatedFilter_WithReason_IncludesReason() + { + var warnings = LayoutWarnings.ForUnevaluatedFilter("MyView", "istype Foo", "unsupported construct"); + + var message = Assert.Single(warnings); + Assert.Contains("unsupported construct", message); + } + + /// An empty bracket-filter list produces no warnings. + [Fact] + public void ForUnevaluatedExposeBracketFilter_Empty_ReturnsEmpty() + { + Assert.Empty(LayoutWarnings.ForUnevaluatedExposeBracketFilter("View", [])); + } + + /// A non-empty bracket-filter list produces a single warning naming the view. + [Fact] + public void ForUnevaluatedExposeBracketFilter_NonEmpty_ReturnsWarning() + { + var warnings = LayoutWarnings.ForUnevaluatedExposeBracketFilter("MyView", ["@Safety"]); + + var message = Assert.Single(warnings); + Assert.Contains("MyView", message); + Assert.Contains("bracket-filter", message); Assert.Contains("not yet evaluated", message); } } From 2832a837b264be71b992b40035925d3ef01e3881 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 17:06:35 -0400 Subject: [PATCH 2/6] test: add filtering + metadata AST builder tests (Phase 1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FilterExpressionEvaluatorTests.cs | 211 +++++++++++++++ .../Filtering/FilterExpressionParserTests.cs | 253 ++++++++++++++++++ .../Semantic/AstBuilderMetadataTests.cs | 224 ++++++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionEvaluatorTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs diff --git a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionEvaluatorTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionEvaluatorTests.cs new file mode 100644 index 00000000..7e0eb0ac --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionEvaluatorTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) DemaConsulting. All rights reserved. +// Licensed under the MIT License. + +using DemaConsulting.SysML2Tools.Filtering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Tools.Stdlib; + +namespace DemaConsulting.SysML2Tools.Tests.Filtering; + +/// +/// Tests for . +/// +public sealed class FilterExpressionEvaluatorTests +{ + /// Loads a workspace from inline SysML v2 source text (temp-file round trip). + private static async Task LoadAsync(string source) + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, source, TestContext.Current.CancellationToken); + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + Assert.NotNull(result.Workspace); + return result.Workspace!; + } + finally + { + File.Delete(tempFile); + } + } + + private const string Source = """ + package P { + metadata def Safety { + attribute isMandatory : Boolean; + attribute level : String; + } + + metadata def Critical { + } + + part def Engine { + @Safety { + isMandatory = true; + level = "high"; + } + } + + part def Wiring { + @Safety { + isMandatory = false; + } + } + + part def Housing { + } + } + """; + + /// A classification test matches only candidates carrying the referenced metadata annotation. + [Fact] + public async Task Evaluate_ClassificationTest_MatchesOnlyAnnotatedCandidates() + { + var workspace = await LoadAsync(Source); + var expression = new ClassificationTestExpression("Safety"); + + var result = FilterExpressionEvaluator.Evaluate( + workspace, ["P::Engine", "P::Wiring", "P::Housing"], expression); + + Assert.Equal(["P::Engine", "P::Wiring"], result.MatchedQualifiedNames.OrderBy(n => n, StringComparer.Ordinal)); + } + + /// A qualified classification test also matches via its qualified type reference. + [Fact] + public async Task Evaluate_QualifiedClassificationTest_Matches() + { + var workspace = await LoadAsync(Source); + var expression = new ClassificationTestExpression("P::Safety"); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine"], expression); + + Assert.Contains("P::Engine", result.MatchedQualifiedNames); + } + + /// A classification test that no candidate carries matches nothing. + [Fact] + public async Task Evaluate_ClassificationTestNoMatch_ReturnsEmpty() + { + var workspace = await LoadAsync(Source); + var expression = new ClassificationTestExpression("Critical"); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring", "P::Housing"], expression); + + Assert.Empty(result.MatchedQualifiedNames); + } + + /// not inverts the classification test's match set. + [Fact] + public async Task Evaluate_Not_InvertsMatchSet() + { + var workspace = await LoadAsync(Source); + var expression = new NotFilterExpression(new ClassificationTestExpression("Safety")); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring", "P::Housing"], expression); + + Assert.Equal(["P::Housing"], result.MatchedQualifiedNames); + } + + /// and matches only candidates satisfying both operands. + [Fact] + public async Task Evaluate_And_MatchesIntersection() + { + var workspace = await LoadAsync(Source); + var expression = new BooleanFilterExpression( + BooleanConnective.And, + "and", + new ClassificationTestExpression("Safety"), + new AttributeReadExpression("Safety", "isMandatory")); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring", "P::Housing"], expression); + + Assert.Equal(["P::Engine"], result.MatchedQualifiedNames); + } + + /// or matches candidates satisfying either operand. + [Fact] + public async Task Evaluate_Or_MatchesUnion() + { + var workspace = await LoadAsync(Source); + var expression = new BooleanFilterExpression( + BooleanConnective.Or, + "or", + new ClassificationTestExpression("Critical"), + new ClassificationTestExpression("Safety")); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring", "P::Housing"], expression); + + Assert.Equal(["P::Engine", "P::Wiring"], result.MatchedQualifiedNames.OrderBy(n => n, StringComparer.Ordinal)); + } + + /// A bare attribute read is truthy only when the captured boolean value is true. + [Fact] + public async Task Evaluate_BareAttributeRead_TrueOnlyWhenBooleanValueTrue() + { + var workspace = await LoadAsync(Source); + var expression = new AttributeReadExpression("Safety", "isMandatory"); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring"], expression); + + Assert.Equal(["P::Engine"], result.MatchedQualifiedNames); + } + + /// An attribute read comparison matches candidates whose captured value equals the literal. + [Fact] + public async Task Evaluate_ComparisonEqual_MatchesEqualValue() + { + var workspace = await LoadAsync(Source); + var expression = new ComparisonFilterExpression( + new AttributeReadExpression("Safety", "level"), + ComparisonOperator.Equal, + new LiteralFilterExpression(FilterLiteralKind.String, StringValue: "high")); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring"], expression); + + Assert.Equal(["P::Engine"], result.MatchedQualifiedNames); + } + + /// An attribute read comparison with != matches candidates whose value differs. + [Fact] + public async Task Evaluate_ComparisonNotEqual_MatchesDifferingValue() + { + var workspace = await LoadAsync(Source); + var expression = new ComparisonFilterExpression( + new AttributeReadExpression("Safety", "isMandatory"), + ComparisonOperator.NotEqual, + new LiteralFilterExpression(FilterLiteralKind.Boolean, BooleanValue: true)); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Engine", "P::Wiring"], expression); + + Assert.Equal(["P::Wiring"], result.MatchedQualifiedNames); + } + + /// An attribute read against a candidate with no matching metadata annotation is absent (never matches). + [Fact] + public async Task Evaluate_AttributeReadAbsent_NeverMatchesComparison() + { + var workspace = await LoadAsync(Source); + var expression = new ComparisonFilterExpression( + new AttributeReadExpression("Safety", "isMandatory"), + ComparisonOperator.NotEqual, + new LiteralFilterExpression(FilterLiteralKind.Boolean, BooleanValue: true)); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::Housing"], expression); + + Assert.Empty(result.MatchedQualifiedNames); + } + + /// Evaluation never throws for candidates missing from the workspace's declarations. + [Fact] + public async Task Evaluate_UnknownCandidate_SkipsGracefully() + { + var workspace = await LoadAsync(Source); + var expression = new ClassificationTestExpression("Safety"); + + var result = FilterExpressionEvaluator.Evaluate(workspace, ["P::DoesNotExist"], expression); + + Assert.Empty(result.MatchedQualifiedNames); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs new file mode 100644 index 00000000..b568b033 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs @@ -0,0 +1,253 @@ +// Copyright (c) DemaConsulting. All rights reserved. +// Licensed under the MIT License. + +using DemaConsulting.SysML2Tools.Filtering; + +namespace DemaConsulting.SysML2Tools.Tests.Filtering; + +/// +/// Tests for . +/// +public sealed class FilterExpressionParserTests +{ + /// A bare classification test parses into a . + [Fact] + public void Parse_ClassificationTest_ReturnsClassificationTestExpression() + { + var result = FilterExpressionParser.Parse("@Safety"); + + Assert.Empty(result.Diagnostics); + var expression = Assert.IsType(result.Expression); + Assert.Equal("Safety", expression.TypeName); + } + + /// A qualified classification test preserves the fully-qualified type name text. + [Fact] + public void Parse_QualifiedClassificationTest_PreservesQualifiedName() + { + var result = FilterExpressionParser.Parse("@Pkg::Safety"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal("Pkg::Safety", expression.TypeName); + } + + /// An and connective builds a with both operands. + [Fact] + public void Parse_AndConnective_ReturnsBooleanExpression() + { + var result = FilterExpressionParser.Parse("@Safety and @Critical"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.And, expression.Connective); + Assert.Equal("and", expression.OperatorText); + Assert.IsType(expression.Left); + Assert.IsType(expression.Right); + } + + /// An or connective builds a . + [Fact] + public void Parse_OrConnective_ReturnsBooleanExpression() + { + var result = FilterExpressionParser.Parse("@Safety or @Critical"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.Or, expression.Connective); + } + + /// An xor connective builds a . + [Fact] + public void Parse_XorConnective_ReturnsBooleanExpression() + { + var result = FilterExpressionParser.Parse("@Safety xor @Critical"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.Xor, expression.Connective); + } + + /// The & symbol spelling maps to the And connective, preserving its source spelling. + [Fact] + public void Parse_AmpSymbol_ReturnsAndWithSymbolSpelling() + { + var result = FilterExpressionParser.Parse("@Safety & @Critical"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.And, expression.Connective); + Assert.Equal("&", expression.OperatorText); + } + + /// The | symbol spelling maps to the Or connective, preserving its source spelling. + [Fact] + public void Parse_PipeSymbol_ReturnsOrWithSymbolSpelling() + { + var result = FilterExpressionParser.Parse("@Safety | @Critical"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.Or, expression.Connective); + Assert.Equal("|", expression.OperatorText); + } + + /// not builds a . + [Fact] + public void Parse_Not_ReturnsNotExpression() + { + var result = FilterExpressionParser.Parse("not @Safety"); + + var expression = Assert.IsType(result.Expression); + Assert.IsType(expression.Operand); + } + + /// Parenthesization groups sub-expressions without altering their meaning. + [Fact] + public void Parse_Parenthesized_ReturnsInnerExpression() + { + var result = FilterExpressionParser.Parse("(@Safety and @Critical) or @Other"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.Or, expression.Connective); + Assert.IsType(expression.Left); + } + + /// A bare (as Type).attribute read builds an . + [Fact] + public void Parse_AttributeRead_ReturnsAttributeReadExpression() + { + var result = FilterExpressionParser.Parse("(as Safety).isMandatory"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal("Safety", expression.TypeName); + Assert.Equal("isMandatory", expression.AttributeName); + } + + /// An attribute read compared with == against a boolean literal builds a comparison. + [Fact] + public void Parse_AttributeReadEqualsBoolean_ReturnsComparisonExpression() + { + var result = FilterExpressionParser.Parse("(as Safety).isMandatory == true"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(ComparisonOperator.Equal, expression.Operator); + Assert.Equal(FilterLiteralKind.Boolean, expression.Right.Kind); + Assert.True(expression.Right.BooleanValue); + } + + /// An attribute read compared with != against a string literal builds a comparison. + [Fact] + public void Parse_AttributeReadNotEqualsString_ReturnsComparisonExpression() + { + var result = FilterExpressionParser.Parse("(as Safety).level != \"low\""); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(ComparisonOperator.NotEqual, expression.Operator); + Assert.Equal(FilterLiteralKind.String, expression.Right.Kind); + Assert.Equal("low", expression.Right.StringValue); + } + + /// An attribute read compared with a number literal builds a comparison. + [Fact] + public void Parse_AttributeReadEqualsNumber_ReturnsComparisonExpression() + { + var result = FilterExpressionParser.Parse("(as Safety).level == 4"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(FilterLiteralKind.Number, expression.Right.Kind); + Assert.Equal(4, expression.Right.NumberValue); + } + + /// istype is outside the Phase 1 subset and produces an "unsupported construct" diagnostic. + [Fact] + public void Parse_Istype_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("x istype Safety"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// hastype is outside the Phase 1 subset and produces an "unsupported construct" diagnostic. + [Fact] + public void Parse_Hastype_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("x hastype Safety"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// all is outside the Phase 1 subset and produces an "unsupported construct" diagnostic. + [Fact] + public void Parse_All_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("all Safety"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// Arithmetic is outside the Phase 1 subset and produces an "unsupported construct" diagnostic. + [Fact] + public void Parse_Arithmetic_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("1 + 2"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// A conditional (if) expression is outside the Phase 1 subset. + [Fact] + public void Parse_Conditional_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("if @Safety ? true else false"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// General feature-chain navigation (a plain member access, no cast) is unsupported in Phase 1. + [Fact] + public void Parse_GeneralFeatureChainNavigation_ReturnsUnsupportedConstructDiagnostic() + { + var result = FilterExpressionParser.Parse("someFeature.someAttribute"); + + Assert.Null(result.Expression); + Assert.Contains(result.Diagnostics, d => d.Message.Contains("Unsupported filter construct")); + } + + /// Malformed syntax never throws and reports a syntax-error diagnostic. + [Fact] + public void Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic() + { + var result = FilterExpressionParser.Parse("@Safety and and"); + + Assert.Null(result.Expression); + Assert.NotEmpty(result.Diagnostics); + } + + /// + /// Round-trip: pretty-printing a parsed expression and re-parsing the printed text yields a + /// semantically-equivalent tree, for every Phase 1 construct. + /// + [Theory] + [InlineData("@Safety")] + [InlineData("@Pkg::Safety")] + [InlineData("@Safety and @Critical")] + [InlineData("@Safety or @Critical")] + [InlineData("@Safety xor @Critical")] + [InlineData("not @Safety")] + [InlineData("(as Safety).isMandatory")] + [InlineData("(as Safety).isMandatory == true")] + [InlineData("(as Safety).level != \"low\"")] + [InlineData("(as Safety).level == 4")] + [InlineData("@Safety and @Critical or not @Other")] + public void Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree(string expressionText) + { + var first = FilterExpressionParser.Parse(expressionText); + Assert.NotNull(first.Expression); + + var printed = first.Expression!.ToString(); + var second = FilterExpressionParser.Parse(printed!); + + Assert.NotNull(second.Expression); + Assert.Equal(first.Expression, second.Expression); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs new file mode 100644 index 00000000..a33c51cc --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs @@ -0,0 +1,224 @@ +// Copyright (c) DemaConsulting. All rights reserved. +// Licensed under the MIT License. + +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; +using DemaConsulting.SysML2Tools.Stdlib; + +namespace DemaConsulting.SysML2Tools.Tests.Semantic; + +/// +/// Tests for AstBuilder's metadata-annotation capture (), +/// exercised indirectly through the public entry point +/// (mirroring the existing WorkspaceLoaderTests convention, since AstBuilder is +/// internal). +/// +public sealed class AstBuilderMetadataTests +{ + /// + /// A part def annotated with a bare @Type; metadata reference captures a + /// child with no attribute values. + /// + [Fact] + public async Task AstBuilder_BareMetadataAnnotation_CapturesMetadataNode() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + metadata def Safety { + attribute isMandatory : Boolean; + } + + part def Engine { + @Safety; + } + } + """, + TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + Assert.True(result.Workspace!.Declarations.TryGetValue("P::Engine", out var engine)); + var metadata = Assert.Single(engine!.Children.OfType()); + Assert.Equal("Safety", metadata.TypeReference); + Assert.Empty(metadata.Attributes); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A part def annotated with {@Type{attr = value;}} captures the literal boolean + /// attribute value assigned in the annotation's body. + /// + [Fact] + public async Task AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + metadata def Safety { + attribute isMandatory : Boolean; + } + + part def Engine { + @Safety { + isMandatory = true; + } + } + } + """, + TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + Assert.True(result.Workspace!.Declarations.TryGetValue("P::Engine", out var engine)); + var metadata = Assert.Single(engine!.Children.OfType()); + var attribute = Assert.Single(metadata.Attributes); + Assert.Equal("isMandatory", attribute.Name); + Assert.Equal(MetadataAttributeValueKind.Boolean, attribute.Kind); + Assert.True(attribute.BooleanValue); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A metadata annotation's type reference resolves into a + /// edge when the referenced metadata def + /// exists in scope. + /// + [Fact] + public async Task AstBuilder_MetadataAnnotation_ResolvesTypeReference() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + metadata def Safety { + attribute isMandatory : Boolean; + } + + part def Engine { + @Safety; + } + } + """, + TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + Assert.True(result.Workspace!.Declarations.TryGetValue("P::Engine", out var engine)); + var metadata = Assert.Single(engine!.Children.OfType()); + var edge = Assert.Single(metadata.ResolvedEdges); + Assert.Equal(SysmlEdgeKind.MetadataType, edge.Kind); + Assert.Equal("P::Safety", edge.TargetQualifiedName); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A metadata annotation whose type reference does not resolve produces an + /// "Unresolved reference" warning diagnostic, mirroring every other reference kind + /// ReferenceResolver handles. + /// + [Fact] + public async Task AstBuilder_MetadataAnnotation_UnresolvedType_ProducesWarning() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + part def Engine { + @NoSuchMetadataType; + } + } + """, + TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + Assert.Contains( + result.Diagnostics, + d => d.Message.Contains("Unresolved reference") && d.Message.Contains("NoSuchMetadataType")); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// An expose <path>::**[<expr>] bracket-filter member captures its raw + /// expression text on , without + /// evaluating it (Phase 1 capture-only per the ROADMAP). + /// + [Fact] + public async Task AstBuilder_ExposeBracketFilter_CapturesRawText() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + metadata def Safety { + attribute isMandatory : Boolean; + } + + part def Engine { + @Safety; + } + + view V { + expose P::**[@Safety]; + } + } + """, + TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + Assert.True(result.Workspace!.Declarations.TryGetValue("P::V", out var view)); + var viewNode = Assert.IsType(view); + var bracketFilterText = Assert.Single(viewNode.ExposeBracketFilterTexts); + Assert.Equal("@Safety", bracketFilterText); + } + finally + { + File.Delete(tempFile); + } + } +} From e2d8c6c480b93ab56637e7eaf2588b8fef40bf5a Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 17:19:33 -0400 Subject: [PATCH 3/6] fix: preserve original whitespace in filter-expression text capture; re-associate DOT/attribute-read across boolean chains - AstBuilder now reconstructs filter/bracket-filter expression text from the original input stream (preserving inter-token whitespace) instead of ParserRuleContext.GetText(), which concatenates tokens with no separator and breaks keyword boundaries on re-lex (e.g. '@Safety and (as Safety)' round-tripped as the unlexable '@Safetyand(asSafety)'). - FilterExpressionParser now re-associates a DOT-based attribute read onto the rightmost operand of a boolean/not chain, since this grammar's DOT binds looser than the boolean connectives (so 'X and (as T).attr' parses as 'DOT(AND(X, (as T)), attr)' rather than the intuitively-expected 'AND(X, DOT((as T), attr))'). This supports the canonical OMG filtering idiom without requiring extra parentheses. - Added FilterExpressionEvaluatorTests.cs and a new-fixture end-to-end rendering test (safety-metadata-filter.sysml under test/SysMLModels/Custom) proving the evaluator narrows General View rendered scope correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Filtering/FilterExpressionParser.cs | 80 ++++++++++++++++--- .../Semantic/Model/AstBuilder.cs | 21 ++++- .../Filtering/FilterExpressionParserTests.cs | 23 ++++++ .../Rendering/RenderIntegrationTests.cs | 69 ++++++++++++++++ .../Custom/safety-metadata-filter.sysml | 34 ++++++++ 5 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 test/SysMLModels/Custom/safety-metadata-filter.sysml diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs index 3c325290..317824f3 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs @@ -157,10 +157,14 @@ private static SysmlDiagnostic Diagnostic(string message) => return BuildComparison(context, operands, diagnostics); } - // (as Type).attribute — a DOT read on a metadata-cast base expression. + // (as Type).attribute — a DOT read on a metadata-cast base expression. Note: in this + // grammar DOT binds looser than the boolean connectives (see BuildAttributeReadOnto's + // remarks), so a DOT node's left operand may itself be an already-assembled boolean + // chain (e.g. "@Safety and (as Safety)" for source text "@Safety and (as Safety).x") + // that needs the attribute read re-associated onto its rightmost operand. if (context.DOT() is not null && operands.Length == 1 && context.qualifiedName().Length > 0) { - return BuildAttributeRead(context, diagnostics); + return BuildAttributeReadOnto(operands[0], context.qualifiedName(0).GetText(), diagnostics); } // Parenthesized sub-expression with no other operator present: baseExpression covers the @@ -216,20 +220,72 @@ private static SysmlDiagnostic Diagnostic(string message) => return new ComparisonFilterExpression(attributeRead, op, right); } - /// Builds an from a (as Type).attribute DOT node. - private static FilterExpression? BuildAttributeRead( - SysMLv2Parser.OwnedExpressionContext context, List diagnostics) + /// + /// Builds an for a (as Type).attribute DOT read + /// whose left-hand operand is . + /// + /// + /// In this project's ANTLR grammar (SysMLv2Parser.g4's ownedExpression rule), + /// DOT binds looser than the boolean connectives (and/or/xor/ + /// &/|) and not: for source text like + /// @Safety and (as Safety).isMandatory, the parser builds the boolean chain + /// @Safety and (as Safety) first (as its two operands are adjacent in the token + /// stream), then wraps the trailing .isMandatory DOT around that *entire* chain — + /// i.e. the CST shape is DOT(AND(@Safety, (as Safety)), isMandatory), not the + /// intuitively-expected AND(@Safety, DOT((as Safety), isMandatory)). This mirrors the + /// canonical OMG filter-expression idiom (see the SysML v2 "Filtering" training example), + /// so rather than reporting it as unsupported, this method re-associates the attribute read + /// onto the rightmost operand of a boolean/not chain, recursively, producing the + /// intuitively-expected tree. + /// + private static FilterExpression? BuildAttributeReadOnto( + SysMLv2Parser.OwnedExpressionContext left, string attributeName, List diagnostics) { - var baseExpr = context.ownedExpression(0).baseExpression(); - if (baseExpr?.AS() is null || baseExpr.typeReference() is not { } typeRef) + var leftOperands = left.ownedExpression(); + + // Direct case: left is exactly the "(as Type)" cast primary — the attribute read applies + // directly to it. + if (leftOperands.Length == 0 && + left.baseExpression() is { } baseExpr && baseExpr.AS() is not null && + baseExpr.typeReference() is { } typeRef) { - diagnostics.Add(Diagnostic( - $"Unsupported filter construct: '.' navigation is only supported on an '(as Type)' cast, found '{context.GetText()}'.")); - return null; + return new AttributeReadExpression(typeRef.GetText(), attributeName); } - var attributeName = context.qualifiedName(0).GetText(); - return new AttributeReadExpression(typeRef.GetText(), attributeName); + // Re-association case: left is a boolean connective chain — attach the attribute read to + // its rightmost operand instead (see method remarks), keeping the leftmost operand as-is. + if (leftOperands.Length == 2) + { + var (connective, operatorText) = left switch + { + _ when left.AND() is not null => (BooleanConnective.And, "and"), + _ when left.OR() is not null => (BooleanConnective.Or, "or"), + _ when left.XOR() is not null => (BooleanConnective.Xor, "xor"), + _ when left.AMP() is not null => (BooleanConnective.And, "&"), + _ when left.PIPE() is not null => (BooleanConnective.Or, "|"), + _ => ((BooleanConnective?)null, (string?)null), + }; + + if (connective is { } c && operatorText is { } opText) + { + var leftmost = TryBuild(leftOperands[0], diagnostics); + var rightmost = BuildAttributeReadOnto(leftOperands[1], attributeName, diagnostics); + return leftmost is null || rightmost is null + ? null + : new BooleanFilterExpression(c, opText, leftmost, rightmost); + } + } + + // Re-association case: left is a unary "not" — attach the attribute read to its operand. + if (leftOperands.Length == 1 && left.NOT() is not null) + { + var operand = BuildAttributeReadOnto(leftOperands[0], attributeName, diagnostics); + return operand is null ? null : new NotFilterExpression(operand); + } + + diagnostics.Add(Diagnostic( + $"Unsupported filter construct: '.' navigation is only supported on an '(as Type)' cast, found '{left.GetText()}.{attributeName}'.")); + return null; } /// Handles a parenthesized-grouping baseExpression with a single nested expression. diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index fdf86d49..fea09ba9 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -1044,13 +1044,28 @@ private static (string? RenderTargetName, string? FilterExpressionText) ExtractV if (filterExpressionText is null && GetElementFilterMember(item) is { } filterMember) { - filterExpressionText = filterMember.ownedExpression()?.GetText(); + filterExpressionText = filterMember.ownedExpression() is { } filterExpr + ? GetOriginalText(filterExpr) + : null; } } return (renderTargetName, filterExpressionText); } + /// + /// Reconstructs a parser rule context's original source text (preserving whitespace between + /// tokens), unlike which concatenates each + /// token's text with no separators. Required whenever the captured text will later be + /// re-lexed on its own (e.g. FilterExpressionParser.Parse) — without the original + /// inter-token spacing, adjacent keyword/identifier tokens can merge into a single token + /// (e.g. "@Safety and (as Safety)" would otherwise round-trip as + /// "@Safetyand(asSafety)", losing the and/as keyword boundaries). + /// + private static string GetOriginalText(Antlr4.Runtime.ParserRuleContext context) => + context.Start.InputStream.GetText( + new Antlr4.Runtime.Misc.Interval(context.Start.StartIndex, context.Stop.StopIndex)); + /// Extracts the viewRenderingMember() accessor common to both view body item types. private static SysMLv2Parser.ViewRenderingMemberContext? GetViewRenderingMember(Antlr4.Runtime.ParserRuleContext item) => item switch @@ -1206,7 +1221,9 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp // filterPackageMember (multiple bracket filters chained on one path are extremely // rare; the first is representative for the "unevaluated" warning). var filterPackage = namespaceImport.filterPackage(); - var bracketFilterText = filterPackage?.filterPackageMember()?.FirstOrDefault()?.ownedExpression()?.GetText(); + var bracketFilterText = filterPackage?.filterPackageMember()?.FirstOrDefault()?.ownedExpression() is { } bracketExpr + ? GetOriginalText(bracketExpr) + : null; var filterDecl = filterPackage?.filterPackageImportDeclaration(); if (filterDecl is not null) { diff --git a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs index b568b033..69f33b02 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs @@ -130,6 +130,28 @@ public void Parse_AttributeReadEqualsBoolean_ReturnsComparisonExpression() Assert.True(expression.Right.BooleanValue); } + /// + /// The canonical OMG "Filtering" idiom @Safety and (as Safety).isMandatory parses + /// as the intuitively-expected AND(classification-test, attribute-read) tree, not + /// the literal CST shape the grammar produces (a DOT node wrapping the whole AND chain — + /// see FilterExpressionParser.BuildAttributeReadOnto's remarks for why DOT binds + /// looser than the boolean connectives in this grammar and how the parser re-associates + /// the attribute read onto the chain's rightmost operand). + /// + [Fact] + public void Parse_ClassificationTestAndAttributeRead_ReAssociatesDotOntoRightOperand() + { + var result = FilterExpressionParser.Parse("@Safety and (as Safety).isMandatory"); + + var expression = Assert.IsType(result.Expression); + Assert.Equal(BooleanConnective.And, expression.Connective); + var left = Assert.IsType(expression.Left); + Assert.Equal("Safety", left.TypeName); + var right = Assert.IsType(expression.Right); + Assert.Equal("Safety", right.TypeName); + Assert.Equal("isMandatory", right.AttributeName); + } + /// An attribute read compared with != against a string literal builds a comparison. [Fact] public void Parse_AttributeReadNotEqualsString_ReturnsComparisonExpression() @@ -239,6 +261,7 @@ public void Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic() [InlineData("(as Safety).level != \"low\"")] [InlineData("(as Safety).level == 4")] [InlineData("@Safety and @Critical or not @Other")] + [InlineData("@Safety and (as Safety).isMandatory")] public void Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree(string expressionText) { var first = FilterExpressionParser.Parse(expressionText); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs index 793d4d6c..20922c3d 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs @@ -183,6 +183,75 @@ public async Task DiagramRenderer_RenderWorkspace_GeneralViewModel_PngProducesVa } } + /// + /// Path to the safety-metadata-filter test fixture: a package with a metadata def, + /// part definitions carrying @Safety annotations, and two views whose filter + /// statements exercise Phase 1 filter-expression evaluation end-to-end. + /// + private static string SafetyMetadataFilterModel => + Path.Combine(FindSysMLModelsRoot() ?? "SysMLModels", "Custom", "safety-metadata-filter.sysml"); + + /// + /// Rendering the SafetyPartsView (filter @Safety;) from the safety-metadata-filter + /// fixture produces SVG output that includes only the definitions carrying the @Safety + /// metadata annotation (Actuator, Gripper) and excludes the unannotated + /// Bracket definition — proving the evaluator actually narrows the rendered scope. + /// + [Fact] + public async Task DiagramRenderer_RenderWorkspace_SafetyPartsView_FiltersToAnnotatedParts() + { + // Arrange: load the safety-metadata-filter fixture workspace + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([SafetyMetadataFilterModel], stdlibTable); + Assert.NotNull(result.Workspace); // Pre-condition: workspace must load + var diagramRenderer = new DiagramRenderer(); + var svgRenderer = new SvgRenderer(); + var options = new RenderOptions(Themes.Light); + + // Act: render the workspace, and locate the SafetyPartsView output by name + var viewNames = DiagramRenderer.GetViewNames(result.Workspace); + var outputs = diagramRenderer.RenderWorkspace(result.Workspace, svgRenderer, options); + var index = viewNames.ToList().FindIndex(n => n.Contains("SafetyPartsView")); + Assert.True(index >= 0, "SafetyPartsView not found among rendered views"); + var svgText = System.Text.Encoding.UTF8.GetString(((MemoryStream)outputs[index].Data).ToArray()); + + // Assert: only the @Safety-annotated definitions are rendered + Assert.Contains("Actuator", svgText); + Assert.Contains("Gripper", svgText); + Assert.DoesNotContain("Bracket", svgText); + } + + /// + /// Rendering the MandatorySafetyPartsView (filter @Safety and (as Safety).isMandatory;) + /// from the safety-metadata-filter fixture produces SVG output that includes only the + /// Actuator definition (the sole definition whose @Safety annotation has + /// isMandatory = true) — proving boolean-connective and attribute-read evaluation + /// compose correctly end-to-end through the rendering pipeline. + /// + [Fact] + public async Task DiagramRenderer_RenderWorkspace_MandatorySafetyPartsView_FiltersToMandatoryPart() + { + // Arrange: load the safety-metadata-filter fixture workspace + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([SafetyMetadataFilterModel], stdlibTable); + Assert.NotNull(result.Workspace); // Pre-condition: workspace must load + var diagramRenderer = new DiagramRenderer(); + var svgRenderer = new SvgRenderer(); + var options = new RenderOptions(Themes.Light); + + // Act: render the workspace, and locate the MandatorySafetyPartsView output by name + var viewNames = DiagramRenderer.GetViewNames(result.Workspace); + var outputs = diagramRenderer.RenderWorkspace(result.Workspace, svgRenderer, options); + var index = viewNames.ToList().FindIndex(n => n.Contains("MandatorySafetyPartsView")); + Assert.True(index >= 0, "MandatorySafetyPartsView not found among rendered views"); + var svgText = System.Text.Encoding.UTF8.GetString(((MemoryStream)outputs[index].Data).ToArray()); + + // Assert: only the mandatory-Safety-annotated definition is rendered + Assert.Contains("Actuator", svgText); + Assert.DoesNotContain("Gripper", svgText); + Assert.DoesNotContain("Bracket", svgText); + } + /// /// Loading a model that uses same-package short-name specialization produces no /// unresolved-reference diagnostics originating from user-authored files, confirming diff --git a/test/SysMLModels/Custom/safety-metadata-filter.sysml b/test/SysMLModels/Custom/safety-metadata-filter.sysml new file mode 100644 index 00000000..2d5eb64d --- /dev/null +++ b/test/SysMLModels/Custom/safety-metadata-filter.sysml @@ -0,0 +1,34 @@ +package RobotArm { + + metadata def Safety { + attribute isMandatory : Boolean; + } + + metadata def Certified { + } + + part def Actuator { + @Safety { + isMandatory = true; + } + } + + part def Gripper { + @Safety { + isMandatory = false; + } + @Certified; + } + + part def Bracket; + + // Renders only the definitions carrying the Safety metadata annotation. + view def SafetyPartsView { + filter @Safety; + } + + // Renders only the definitions whose Safety annotation is mandatory. + view def MandatorySafetyPartsView { + filter @Safety and (as Safety).isMandatory; + } +} From 7dfa03d417f7a4760b2a3d1426ec4e2366ddf510 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 17:53:53 -0400 Subject: [PATCH 4/6] docs: add reqstream/design/verification artifacts for Filtering subsystem + SysmlMetadataNode (Phase 1) - New subsystem/unit reqstream requirement files for Core.Filtering and SysmlMetadataNode, plus updates to ast-builder/sysml-node/ general-view-layout-strategy/layout-warnings requirement files. - New/updated design and verification docs mirroring the requirement structure, including docs/design/introduction.md Software Structure updates. - .reviewmark.yaml entries for all new reviewable files. - cspell:ignore annotations for new filter-grammar terminology (parenthesization/istype/hastype/LPAREN/RPAREN/unlexable/etc.). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 67 ++++++++++ docs/design/introduction.md | 33 +++-- docs/design/sysml2-tools-core.md | 30 ++++- docs/design/sysml2-tools-core/filtering.md | 96 ++++++++++++++ .../filtering/filter-expression-evaluator.md | 125 ++++++++++++++++++ .../internal/general-view-layout-strategy.md | 34 +++-- .../layout/internal/layout-warnings.md | 35 +++-- .../sysml2-tools-language/semantic/model.md | 31 ++--- .../semantic/model/ast-builder.md | 20 ++- .../semantic/model/sysml-node.md | 43 ++++-- .../sysml2-tools-core/filtering.yaml | 35 +++++ .../filter-expression-evaluator.yaml | 99 ++++++++++++++ .../general-view-layout-strategy.yaml | 37 ++++-- .../layout/internal/layout-warnings.yaml | 28 +++- .../semantic/model/ast-builder.yaml | 25 ++++ .../semantic/model/sysml-metadata-node.yaml | 60 +++++++++ .../semantic/model/sysml-node.yaml | 19 ++- docs/verification/sysml2-tools-core.md | 14 +- .../sysml2-tools-core/filtering.md | 61 +++++++++ .../filtering/filter-expression-evaluator.md | 89 +++++++++++++ .../internal/general-view-layout-strategy.md | 13 +- .../layout/internal/layout-warnings.md | 17 ++- .../sysml2-tools-language/semantic/model.md | 20 ++- .../semantic/model/ast-builder.md | 19 ++- .../semantic/model/sysml-node.md | 8 ++ requirements.yaml | 3 + .../Filtering/FilterExpression.cs | 2 + .../Filtering/FilterExpressionParser.cs | 2 + .../Semantic/Model/AstBuilder.cs | 7 +- .../Filtering/FilterExpressionParserTests.cs | 2 + .../Layout/LayoutWarningsTests.cs | 2 + 31 files changed, 949 insertions(+), 127 deletions(-) create mode 100644 docs/design/sysml2-tools-core/filtering.md create mode 100644 docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md create mode 100644 docs/reqstream/sysml2-tools-core/filtering.yaml create mode 100644 docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml create mode 100644 docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml create mode 100644 docs/verification/sysml2-tools-core/filtering.md create mode 100644 docs/verification/sysml2-tools-core/filtering/filter-expression-evaluator.md diff --git a/.reviewmark.yaml b/.reviewmark.yaml index b07ed0ac..ab3358d4 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -440,6 +440,25 @@ reviews: - "src/DemaConsulting.SysML2Tools.Language/Semantic/AstDeserializer.cs" - "test/DemaConsulting.SysML2Tools.Tests/Semantic/AstSerializerTests.cs" + - id: SysML2Tools-Language-Semantic-Model-SysmlMetadataNode + title: Review that DemaConsulting.SysML2Tools Language Semantic Model SysmlMetadataNode Implementation is Correct + context: + - docs/design/sysml2-tools-language.md + - docs/reqstream/sysml2-tools-language.yaml + - docs/design/sysml2-tools-language/semantic.md + - docs/reqstream/sysml2-tools-language/semantic.yaml + - docs/design/sysml2-tools-language/semantic/model.md + - docs/reqstream/sysml2-tools-language/semantic/model.yaml + paths: + - "docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml" + - "docs/design/sysml2-tools-language/semantic/model.md" + - "docs/verification/sysml2-tools-language/semantic/model.md" + - "src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs" + - "src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs" + - "src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs" + - "src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs" + - id: SysML2Tools-Core-Layout-Design title: Review that DemaConsulting.SysML2Tools Layout Design is Consistent and Complete context: @@ -514,6 +533,54 @@ reviews: - "src/DemaConsulting.SysML2Tools.Core/Io/NamespaceDoc.cs" - "test/DemaConsulting.SysML2Tools.Tests/Io/GlobFileCollectorTests.cs" + - id: SysML2Tools-Core-Filtering-Design + title: Review that DemaConsulting.SysML2Tools Filtering Design is Consistent and Complete + context: + - docs/reqstream/sysml2-tools-core.yaml + - docs/reqstream/sysml2-tools-core/filtering.yaml + paths: + - "docs/design/introduction.md" + - "docs/design/sysml2-tools-core.md" + - "docs/design/sysml2-tools-core/filtering.md" + - "docs/design/sysml2-tools-core/filtering/**/*.md" + + - id: SysML2Tools-Core-Filtering-Verification + title: Review that DemaConsulting.SysML2Tools Filtering Verification is Consistent and Complete + context: + - docs/reqstream/sysml2-tools-core.yaml + - docs/reqstream/sysml2-tools-core/filtering.yaml + paths: + - "docs/verification/introduction.md" + - "docs/verification/sysml2-tools-core.md" + - "docs/verification/sysml2-tools-core/filtering.md" + - "docs/verification/sysml2-tools-core/filtering/**/*.md" + + - id: SysML2Tools-Core-Filtering-AllRequirements + title: Review that All DemaConsulting.SysML2Tools Filtering Requirements are Complete + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + paths: + - "docs/reqstream/sysml2-tools-core/filtering.yaml" + - "docs/reqstream/sysml2-tools-core/filtering/**/*.yaml" + + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator + title: Review that DemaConsulting.SysML2Tools Filtering FilterExpressionEvaluator Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/filtering.md + - docs/reqstream/sysml2-tools-core/filtering.yaml + paths: + - "docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml" + - "docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md" + - "docs/verification/sysml2-tools-core/filtering/filter-expression-evaluator.md" + - "src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs" + - "src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs" + - "src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionEvaluator.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionEvaluatorTests.cs" + # === Layout Internal subsystem and view-strategy units === - id: SysML2Tools-Core-Layout-Internal title: Review that DemaConsulting.SysML2Tools Layout Internal Subsystem is Consistent and Complete diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 91273a6d..7a1479de 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -48,15 +48,19 @@ system, subsystem, and unit levels: - **AstSerializer** (Unit) — serializes SymbolTable + diagnostics to UTF-8 JSON bytes - **AstDeserializer** (Unit) — deserializes bytes back to SymbolTable + diagnostics - **Model** (Subsystem) — semantic model: public model types plus internal build/resolve implementation - - **SysmlNode** (Unit) — public AST node hierarchy: nine types with JSON polymorphism - - **AstBuilder** (Unit) — builds AST from ANTLR4 CST with qualified names and supertype lists + - **SysmlNode** (Unit) — public AST node hierarchy: named-element and view/import node types + - **SysmlMetadataNode** (Unit) — applied metadata annotation node with raw type reference and + captured scalar attribute values + - **AstBuilder** (Unit) — builds AST from ANTLR4 CST with qualified names, metadata + annotations, and raw view filter text - **SymbolTable** (Unit) — registry mapping qualified names to declaration nodes - - **ReferenceResolver** (Unit) — resolves supertype, typing, redefinition, import, satisfy, - verify, allocate, and (in a second pass) dotted feature-chain connect/transition references; - detects circular imports; returns a `SemanticIndex` of resolved edges + - **ReferenceResolver** (Unit) — resolves supertype, typing, metadata-type, + redefinition, import, satisfy, verify, allocate, expose, and (in a second pass) dotted + feature-chain connect/transition references; detects circular imports; returns a + `SemanticIndex` of resolved edges - **SupertypeWalker** (Unit) — walks specialization chains; detects cyclic specialization - - **SysmlEdge** (Unit) — public resolved-reference record (Supertype/Typing/Import/ - Satisfy/Verify/Allocate/Connect/Transition) + - **SysmlEdge** (Unit) — public resolved-reference record (Supertype/Typing/MetadataType/ + Import/Expose/Redefinition/Satisfy/Verify/Allocate/Connect/Transition) - **SemanticIndex** (Unit) — public reverse-lookup index over resolved `SysmlEdge` instances - **SysmlAnnotation** (Unit) — public captured-comment/documentation record (Comment/Documentation) @@ -70,8 +74,12 @@ system, subsystem, and unit levels: stdlib.json.gz (invoked by build.ps1, not part of the MSBuild graph; excluded from the software-items requirements/design/verification tree — see _Scope_) - **Program** (Unit) — entry point: parses stdlib, runs resolution, serializes and compresses to stdlib.json.gz -- **DemaConsulting.SysML2Tools.Core** (System) — core library: layout strategies, rendering - orchestration, and the SysML-coupled rendering pipeline +- **DemaConsulting.SysML2Tools.Core** (System) — core library: layout strategies, + filter-expression evaluation, rendering orchestration, and the SysML-coupled rendering pipeline + - **Filtering** (Subsystem) — parses and evaluates the Phase 1 subset of standalone view + `filter [];` expressions over metadata annotations + - **FilterExpressionEvaluator** (Unit) — filter-expression AST, parser adaptation, and + evaluator for metadata classification tests, boolean connectives, and metadata-attribute reads - **Layout** (Subsystem) — maps the SysML semantic model onto the off-the-shelf `LayoutTree` intermediate representation and delegates geometric placement and routing to the off-the-shelf `DemaConsulting.Rendering.Layout` layered algorithm @@ -157,12 +165,13 @@ reviewers an explicit navigation aid from design to code: - **Antlr/** — ANTLR4-generated C# (committed; not hand-written) - **Internal/** — internal implementation (SysmlDiagnosticListener) - **Semantic/** — semantic model subsystem - - **Model/** — public semantic model types (SysmlNode, AstBuilder, SymbolTable, - ReferenceResolver, SupertypeWalker, SysmlEdge, SemanticIndex, SysmlAnnotation, - SerializedStdlib, AstSerializerContext) + - **Model/** — public semantic model types (SysmlNode, SysmlMetadataNode, AstBuilder, + SymbolTable, ReferenceResolver, SupertypeWalker, SysmlEdge, SemanticIndex, + SysmlAnnotation, SerializedStdlib, AstSerializerContext) - **DemaConsulting.SysML2Tools.Stdlib/** — stdlib library - **Stdlib/** — SysML v2 standard library source files (EPL-2.0; see Stdlib/README.md) - **DemaConsulting.SysML2Tools.Core/** — core library + - **Filtering/** — standalone view-filter expression AST, parser, and evaluator - **Layout/** — layout strategies mapping the model to the off-the-shelf `LayoutTree` - **Internal/** — per-view layout strategies and the `LayeredPlacement` helper - **Rendering/** — SysML-coupled rendering pipeline (`ILayoutStrategy`, `DiagramRenderer`) diff --git a/docs/design/sysml2-tools-core.md b/docs/design/sysml2-tools-core.md index e2958227..f1ced844 100644 --- a/docs/design/sysml2-tools-core.md +++ b/docs/design/sysml2-tools-core.md @@ -2,12 +2,14 @@ ## Architecture -The `DemaConsulting.SysML2Tools` core library provides the Layout, Rendering, and Io subsystems -for SysML v2 diagram generation and shared file-discovery. It depends on +The `DemaConsulting.SysML2Tools` core library provides the Filtering, Layout, Rendering, and Io +subsystems for SysML v2 diagram generation and shared file-discovery. It depends on `DemaConsulting.SysML2Tools.Language` for parsing and semantic analysis, and on `DemaConsulting.SysML2Tools.Stdlib` for the pre-compiled standard library. -The core library provides three subsystems: **Layout**, **Rendering**, and **Io**. The Layout +The core library provides four subsystems: **Filtering**, **Layout**, **Rendering**, and **Io**. +The Filtering subsystem parses and evaluates the Phase 1 subset of standalone +`filter [];` statements against metadata annotations captured by the semantic model. The Layout subsystem maps the SysML semantic model onto the `LayoutTree` intermediate representation — nine immutable node record types covering all SysML diagram elements — which is provided off-the-shelf by the `DemaConsulting.Rendering` package, and delegates geometric placement and routing to the @@ -24,6 +26,10 @@ flowchart TD Language["DemaConsulting.SysML2Tools.Language"] Stdlib["DemaConsulting.SysML2Tools.Stdlib"] end + subgraph Filtering + FilterExpressionParser + FilterExpressionEvaluator + end subgraph Layout LayoutTree LayoutNode @@ -38,8 +44,12 @@ flowchart TD subgraph Io GlobFileCollector end + Language --> FilterExpressionParser + Language --> FilterExpressionEvaluator Language --> DiagramRenderer Stdlib --> DiagramRenderer + DiagramRenderer --> FilterExpressionParser + DiagramRenderer --> FilterExpressionEvaluator DiagramRenderer --> ILayoutStrategy DiagramRenderer --> IRenderer ILayoutStrategy --> LayoutTree @@ -125,8 +135,20 @@ N/A — not a safety-classified software item. 6. Each rendered stream is wrapped in a `RenderOutput` with `SuggestedFileName` derived from the view name and `IRenderer.DefaultExtension`. +### Filtering Data Flow + +1. `AstBuilder` captures a view's standalone `filter [];` statement as raw source text on + `SysmlViewNode.FilterExpressionText`. +2. `GeneralViewLayoutStrategy` passes that raw text to `FilterExpressionParser.Parse`, which + adapts the generated SysML `ownedExpression()` parse tree into the supported Phase 1 + `FilterExpression` AST. +3. When parsing succeeds, `FilterExpressionEvaluator.Evaluate` applies the AST to the already + expose-scoped candidate definitions by reading their directly-owned `SysmlMetadataNode` + children; when parsing fails, layout falls back to the unfiltered scope with a warning. + ## Design Constraints - Platform: multi-targets net8.0, net9.0, and net10.0 on Windows, Linux, and macOS. - SysML v2 parsing, semantic analysis, and standard library are provided by the Language and - Stdlib assemblies; the Core assembly contains only Layout, Rendering, and Io concerns. + Stdlib assemblies; the Core assembly contains only Filtering, Layout, Rendering, and Io + concerns. diff --git a/docs/design/sysml2-tools-core/filtering.md b/docs/design/sysml2-tools-core/filtering.md new file mode 100644 index 00000000..a7905c64 --- /dev/null +++ b/docs/design/sysml2-tools-core/filtering.md @@ -0,0 +1,96 @@ + + +## DemaConsulting.SysML2Tools — Filtering Subsystem + +### Overview + +The Filtering subsystem parses and evaluates the Phase 1 subset of standalone view +`filter [];` expressions captured on `SysmlViewNode.FilterExpressionText`. It contains one +unit, `FilterExpressionEvaluator`, whose implementation spans three tightly-coupled source files: +`FilterExpression` (the abstract syntax tree), `FilterExpressionParser` (the ANTLR-backed parser +adapter), and `FilterExpressionEvaluator` (the metadata-driven boolean evaluator). + +This subsystem is intentionally narrow in Phase 1: it supports metadata classification tests, +boolean connectives, parenthesization, and `(as Type).attribute` reads (bare or compared against a +scalar literal). General feature-chain navigation, arithmetic, conditionals, `istype`, `hastype`, +and `all` are explicitly unsupported and are surfaced as diagnostics instead of exceptions. + +### Interfaces + +```mermaid +flowchart TD + SysmlViewNode --> FilterExpressionParser + FilterExpressionParser --> FilterExpression + FilterExpression --> FilterExpressionEvaluator + FilterExpressionEvaluator --> SysmlWorkspace + FilterExpressionEvaluator --> SysmlMetadataNode +``` + +**FilterExpressionParser**: Raw-text-to-AST adapter. + +- *Type*: Static class. +- *Role*: Provider. +- *Contract*: `FilterParseResult Parse(string expressionText)`. Accepts the raw bracket contents + captured from a standalone `filter [];` member and returns either a supported + `FilterExpression` tree or one-or-more `SysmlDiagnostic` instances explaining why parsing could + not produce one. + +**FilterExpression**: Phase 1 filter-expression AST. + +- *Type*: Abstract record hierarchy. +- *Role*: Data model. +- *Contract*: Represents only the supported Phase 1 subset: classification tests, boolean + connectives, parenthesization, metadata attribute reads, scalar literals, and equality/ + inequality comparisons. Each node's `ToString()` is a canonical pretty-printer. + +**FilterExpressionEvaluator**: Candidate-set evaluator. + +- *Type*: Static class. +- *Role*: Provider. +- *Contract*: `FilterEvaluationResult Evaluate(SysmlWorkspace workspace, IReadOnlyList + candidateQualifiedNames, FilterExpression expression)`. Evaluates the parsed expression against + the supplied candidates and returns the matched subset plus evaluation diagnostics. + +### Design + +1. `AstBuilder` preserves the original token spacing of a view's standalone filter text in + `SysmlViewNode.FilterExpressionText`, so the Filtering subsystem receives a re-lexable fragment + rather than `RuleContext.GetText()`'s whitespace-stripped form. +2. `FilterExpressionParser.Parse` reuses the generated SysML grammar's `ownedExpression()` rule to + parse that fragment, rather than introducing a second filter-specific grammar. This keeps the + accepted syntax aligned with the language parser while constraining the semantic output to the + supported Phase 1 subset. +3. The parser walks the ANTLR CST into a small AST hierarchy (`FilterExpression` and subtypes). + Unsupported nodes do not produce partial ASTs: they append a `SysmlDiagnostic` and return no + expression. The AST's `ToString()` implementations form the subsystem's canonical + pretty-printer, used by the round-trip tests to prove parser/printer alignment. +4. `FilterExpressionEvaluator.Evaluate` treats the caller-supplied `candidateQualifiedNames` as the + only elements eligible to match. For each candidate name it resolves the declaration from + `SysmlWorkspace.Declarations` and evaluates the AST against that node. +5. Classification tests (`@Type`, `@Pkg::Type`) and `(as Type).attribute` reads inspect only the + candidate's directly-owned `SysmlMetadataNode` children. A metadata annotation matches when its + resolved `MetadataType` edge points at the requested type, when that target ends with + `"::Type"` for a bare-name filter, or — if the metadata type never resolved — when the raw + `TypeReference` text itself matches, allowing graceful fallback for otherwise-usable models. +6. Bare attribute reads are boolean predicates: they succeed only when the addressed metadata + attribute exists and its captured literal kind is Boolean with value `true`. Equality and + inequality comparisons support Boolean, Number, and String literals. A missing annotation or a + missing attribute is treated conservatively as false, never as an exception. +7. The subsystem never throws for malformed or unsupported filter text. `GeneralViewLayoutStrategy` + uses parser diagnostics as the reason string when it falls back to rendering the unfiltered + resolved scope. + +### Design Constraints + +- The parser depends on the generated `SysMLv2Lexer`/`SysMLv2Parser` from the Language system and + therefore accepts only syntax valid under the repository's committed grammar. +- Phase 1 supports metadata-driven filtering only. Feature-chain navigation other than the + `(as Type).attribute` metadata read is intentionally rejected. +- The evaluator is read-only over `SysmlWorkspace`; it never mutates declarations or resolved + edges. + +### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| SysML2Tools-Core-Filtering-StandaloneViewFilterEvaluation | `Parse`, `Evaluate`, and `FilterExpression.ToString()` | diff --git a/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md b/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md new file mode 100644 index 00000000..28d43dbe --- /dev/null +++ b/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md @@ -0,0 +1,125 @@ + + +### FilterExpressionEvaluator + +#### Purpose + +`FilterExpressionEvaluator` implements the Phase 1 standalone view-filter capability end to end: +it defines the `FilterExpression` AST hierarchy, adapts the generated SysML parser's +`ownedExpression()` CST into that AST, and evaluates the result against a caller-supplied set of +semantic-model candidates. Keeping the AST, parser, and evaluator together in one documented unit +reflects the implementation's tight coupling: none of the three artifacts is independently useful. + +#### Data Model + +The unit exposes three public record families plus two result records: + +| Type | Purpose | +| --- | --- | +| `FilterExpression` | Abstract base for every supported Phase 1 predicate | +| `ClassificationTestExpression` | `@Type` / `@Pkg::Type` metadata-presence predicate | +| `BooleanFilterExpression` / `NotFilterExpression` | Binary and unary boolean composition | +| `AttributeReadExpression` | `(as Type).attribute` metadata-attribute read | +| `LiteralFilterExpression` | Scalar Boolean/Number/String literal | +| `ComparisonFilterExpression` | `==` / `!=` comparison of an attribute read against a literal | +| `FilterParseResult` | Parser output: AST or diagnostics | +| `FilterEvaluationResult` | Evaluator output: matched candidate names plus diagnostics | + +Every `FilterExpression` subtype overrides `ToString()` as a canonical pretty-printer. The helper +`FilterExpression.Parenthesize` inserts parentheses only where a compound child could otherwise be +misread when embedded into another expression. + +#### Key Methods + +##### `FilterExpressionParser.Parse(string expressionText)` + +Creates an ANTLR lexer/parser over the raw filter fragment and invokes `SysMLv2Parser.ownedExpression()`. +A custom `CollectingErrorListener` captures syntax errors as `SysmlDiagnostic` entries targeting a +virtual file path (`[filter-expression]`) so parsing never writes to the console or throws on +malformed input. If ANTLR reports no syntax errors, `Parse` delegates to `TryBuild` to adapt the +CST into the Phase 1 AST. + +##### `FilterExpressionParser.TryBuild(OwnedExpressionContext, diagnostics)` + +Performs a shape-driven CST walk restricted to the supported subset: + +- prefix classification tests become `ClassificationTestExpression` +- `and`/`or`/`xor`/`&`/`|` become `BooleanFilterExpression` +- `not` becomes `NotFilterExpression` +- `==`/`!=` become `ComparisonFilterExpression` when the left side is an attribute read and the + right side is a scalar literal +- parenthesized base expressions recurse into their inner expression + +Every other CST shape appends an "Unsupported filter construct" diagnostic and returns no AST. + +##### `FilterExpressionParser.BuildAttributeReadOnto(left, attributeName, diagnostics)` + +Builds an `AttributeReadExpression` from the grammar's DOT form. In this repository's +`ownedExpression` grammar, DOT binds looser than the boolean connectives, so the canonical SysML +idiom `@Safety and (as Safety).isMandatory` parses as `DOT(AND(@Safety, (as Safety)), isMandatory)` +rather than `AND(@Safety, DOT((as Safety), isMandatory))`. `BuildAttributeReadOnto` repairs that +shape by re-associating the attribute read onto the boolean chain's rightmost operand (or the +operand of a unary `not`), producing the intuitive AST the evaluator expects. + +##### `FilterExpressionEvaluator.Evaluate(workspace, candidateQualifiedNames, expression)` + +Iterates the supplied candidate names in order, resolves each to a `SysmlNode` in +`workspace.Declarations`, evaluates the AST against that node, and returns the subset whose result +is `true`. Missing candidates are skipped silently, preserving the evaluator's no-throw contract. + +##### `FilterExpressionEvaluator.ReadAttribute(node, attributeRead)` / `FindMetadata(node, typeName)` + +Locate the first directly-owned `SysmlMetadataNode` child whose resolved `MetadataType` edge (or, +when unresolved, raw `TypeReference`) matches the requested type name. `ReadAttribute` then returns +that annotation's first matching `MetadataAttributeValue` by simple attribute name. + +##### `FilterExpressionEvaluator.EvaluateComparison(node, comparison)` + +Reads the attribute value and compares it against the literal using `ValuesEqual`. Absent metadata +or absent attributes evaluate conservatively as false regardless of operator; there is no implicit +three-valued logic or default-value synthesis in Phase 1. + +#### Error Handling + +`FilterExpressionParser` never throws for unsupported constructs or malformed syntax. Syntax errors +arrive via `CollectingErrorListener`; unsupported constructs are diagnosed explicitly during the CST +adaptation pass. `FilterExpressionEvaluator` uses ordinary false/empty results for unknown +candidates, missing metadata, and missing attributes. Diagnostics in `FilterEvaluationResult` are +currently always empty because evaluation of an already-supported AST cannot fail, but the result +shape leaves room for future evaluation-time diagnostics without a breaking API change. + +#### Dependencies + +- `SysMLv2Lexer` / `SysMLv2Parser` (Language Parser subsystem) — reusable grammar implementation +- `SysmlDiagnostic` / `DiagnosticSeverity` (Language Parser subsystem) — parse diagnostics +- `SysmlWorkspace`, `SysmlNode`, `SysmlMetadataNode`, `MetadataAttributeValue`, and + `SysmlEdgeKind.MetadataType` (Language Semantic subsystem) — evaluation inputs and metadata + resolution evidence +- .NET base class library numeric parsing/comparison helpers — scalar literal handling + +#### Callers + +- `GeneralViewLayoutStrategy` parses `SysmlViewNode.FilterExpressionText` with + `FilterExpressionParser.Parse`, evaluates successful ASTs with `FilterExpressionEvaluator.Evaluate`, + narrows its candidate definition set to the matched subset, and falls back to an unfiltered + render with a warning when parsing produces diagnostics. +- `FilterExpressionParserTests` and `FilterExpressionEvaluatorTests` exercise the parser, + pretty-printer, and evaluator directly. + +#### Requirements Traceability + +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-ClassificationTests` — + `FilterExpressionParser.Parse`, `FilterExpressionEvaluator.FindMetadata`, + `FilterExpressionEvaluator.Evaluate` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-BooleanConnectives` — + `FilterExpressionParser.TryBuild`, `BooleanFilterExpression.ToString()`, + `FilterExpressionEvaluator.Evaluate` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-AttributeReads` — + `FilterExpressionParser.BuildAttributeReadOnto`, + `FilterExpressionParser.BuildComparison`, `FilterExpressionEvaluator.ReadAttribute`, + `FilterExpressionEvaluator.EvaluateComparison` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-UnsupportedConstructDiagnostics` — + `CollectingErrorListener`, `FilterExpressionParser.Unsupported`, + `FilterExpressionParser.TryBuildLiteral`, `FilterExpressionEvaluator.Evaluate` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-RoundTripPrettyPrinting` — + `FilterExpression.ToString()` overrides and `FilterExpressionParser.Parse` diff --git a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index 3aaca479..4c8f1ade 100644 --- a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -42,19 +42,26 @@ relationships. Entry point. First resolves the view's exposed-name scope via the shared `ExposeScopeResolver.ResolveExposedScope` (see the *ExposeScopeResolver* unit chapter), then calls `CollectDefinitions` to gather user definitions restricted to that scope (or every definition when -no scope applies); returns a minimal 200×100 empty `LayoutTree` when none are found. Otherwise -groups the definitions by package with `GroupByPackage`, resolves the specialization/membership/ -attribute-typing/redefinition relationships into qualified-name edges with `BuildModelEdges`, -builds the single input `LayoutGraph` with `BuildGraph`, and places the whole graph with one -`HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` -call — passing the desired root-scope leaf algorithm through the options parameter (not +no scope applies); returns a minimal 200×100 empty `LayoutTree` when none are found. If the view +carries standalone `FilterExpressionText`, the method next parses it with +`FilterExpressionParser.Parse`; a successful parse is evaluated with +`FilterExpressionEvaluator.Evaluate` over the already expose-scoped candidate definitions, narrowing +`defs` to the matched subset and returning the same minimal empty canvas when that subset is empty. +A parse failure or unsupported Phase 1 construct does not abort layout: the first parser +diagnostic message is remembered as the warning reason and the method continues rendering the +unfiltered resolved scope. The remaining pipeline is unchanged: definitions are grouped by package +with `GroupByPackage`, the specialization/membership/attribute-typing/redefinition relationships are +resolved into qualified-name edges with `BuildModelEdges`, the single input `LayoutGraph` is built +with `BuildGraph`, and the whole graph is placed with one +`HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` call — +passing the desired root-scope leaf algorithm through the options parameter (not `graph.Set(CoreOptions.Algorithm, …)`) so a caller going through `LayoutEngine.Layout(graph)` later is never misled into skipping the hierarchical engine. When any package folder was depth-truncated, `DecorateTruncatedFolders` stamps each truncated folder's "+N more…" ellipsis label onto its placed -box. Finally, when `context.ViewNode?.FilterExpressionText` is non-null, attaches the -"parsed but not yet evaluated" warning (from `LayoutWarnings.ForUnevaluatedFilter`) to the returned -tree's `Warnings` via the `LayoutTree with { Warnings = … }` record-copy idiom, leaving the -resolved (unfiltered) scope's content unchanged. +box. Finally, the returned tree's `Warnings` concatenates +`LayoutWarnings.ForUnevaluatedFilter` (only when standalone filter parsing/evaluation failed) with +`LayoutWarnings.ForUnevaluatedExposeBracketFilter` (for the still-capture-only +`expose ::**[]` form) via the `LayoutTree with { Warnings = … }` record-copy idiom. ###### `CollectDefinitions(workspace, theme, scope)` @@ -164,8 +171,11 @@ produces valid geometry, so no crossing warnings are emitted. - `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope` and `IsInSubjectScope` supply the shared `expose`-scoping used by `BuildLayout` and `CollectDefinitions`. -- `LayoutWarnings` (Layout Internal subsystem) — `ForUnevaluatedFilter` supplies the - "parsed but not yet evaluated" filter-expression warning text. +- `FilterExpressionParser` and `FilterExpressionEvaluator` (Filtering subsystem) — parse and + evaluate standalone `filter [];` statements over the already expose-scoped definition set. +- `LayoutWarnings` (Layout Internal subsystem) — `ForUnevaluatedFilter` and + `ForUnevaluatedExposeBracketFilter` supply the warning text for standalone-filter fallback and + still-unevaluated expose bracket filters. - The `LayoutTree`, `LayoutBox`, `LayoutCompartment`, `LayoutLine`, `LayoutLabel`, and `Point2D` data types (`DemaConsulting.Rendering`). - `FeatureMembership` (private record) — carries the keyword, nullable type reference, simple diff --git a/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md b/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md index 6f46ca28..676dfafd 100644 --- a/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md +++ b/docs/design/sysml2-tools-core/layout/internal/layout-warnings.md @@ -3,14 +3,14 @@ ##### Purpose `LayoutWarnings` builds the non-fatal layout-quality warning messages surfaced on a `LayoutTree` -from the `DemaConsulting.Rendering` package. -Its single responsibility is to turn a count of connectors that had to cross a box into the -human-readable warning text for a view. +from the `DemaConsulting.Rendering` package. Its responsibility is to turn layout-quality and +deferred-filtering conditions into the human-readable warning text for a view. ##### Data Model -`LayoutWarnings` is a static class with no instance state. Inputs are the view name and the number -of crossing connectors. Output is a read-only list of warning strings. +`LayoutWarnings` is a static class with no instance state. Inputs are the view name together with +either a crossing count, a standalone filter-expression failure, or a list of bracket-filter +expressions. Output is a read-only list of warning strings. ##### Key Methods @@ -23,17 +23,24 @@ Returns the warnings for a view: is rendered in singular form for a count of one and plural form otherwise, and the count is formatted with the invariant culture. -###### `ForUnevaluatedFilter(viewName, filterExpressionText)` +###### `ForUnevaluatedFilter(viewName, filterExpressionText, reason = null)` Returns the warnings for a view's declared filter expression: 1. When `filterExpressionText` is `null` (the view has no `filter [];` member), an empty list is returned. -2. Otherwise a single warning string is produced naming the view: `"View '{viewName}' declares a - filter expression, which is parsed but not yet evaluated; all elements in the resolved scope - are rendered unfiltered."` The raw expression text itself is not interpolated into the message - (only its presence matters) — full filter expression evaluation is deferred future work (see - ROADMAP.md). +2. Otherwise a single warning string is produced naming the view and stating that the filter + expression could not be evaluated; when `reason` is non-empty, it is appended parenthetically. + The raw expression text itself is not interpolated into the message — only its presence matters. + +###### `ForUnevaluatedExposeBracketFilter(viewName, bracketFilterTexts)` + +Returns the warnings for a view's bracketed `expose ::**[]` filters: + +1. When `bracketFilterTexts` is empty, an empty list is returned. +2. Otherwise a single warning string is produced naming the view, reporting how many bracket + filters were declared, and stating that the expressions were parsed but not yet evaluated in + Phase 1. ##### Error Handling @@ -49,5 +56,7 @@ empty list and any string view name is accepted. View layout strategies that route connectors call `LayoutWarnings.ForCrossings` to attach crossing warnings to the `LayoutTree` they produce. `GeneralViewLayoutStrategy` calls -`LayoutWarnings.ForUnevaluatedFilter` to attach the "not yet evaluated" filter warning when a -view's `FilterExpressionText` is non-null. +`LayoutWarnings.ForUnevaluatedFilter` to attach the standalone-filter fallback warning when a +view's `FilterExpressionText` cannot be evaluated, and +`LayoutWarnings.ForUnevaluatedExposeBracketFilter` when a view declares capture-only bracket +filters. diff --git a/docs/design/sysml2-tools-language/semantic/model.md b/docs/design/sysml2-tools-language/semantic/model.md index 957ed580..8f91d537 100644 --- a/docs/design/sysml2-tools-language/semantic/model.md +++ b/docs/design/sysml2-tools-language/semantic/model.md @@ -3,11 +3,11 @@ #### Overview The Semantic Model subsystem provides the public semantic model types (`SysmlNode` and its -subtypes, `SysmlEdge`, `SysmlAnnotation`, `SemanticIndex`) alongside the internal build/resolve -implementation of the semantic loading pipeline (`AstBuilder`, `SymbolTable`, -`ReferenceResolver`, `SupertypeWalker`). It contains eight units: `AstBuilder`, `SymbolTable`, -`ReferenceResolver`, `SupertypeWalker`, `SysmlNode`, `SysmlEdge`, `SemanticIndex`, and -`SysmlAnnotation`. +subtypes, `SysmlMetadataNode`, `SysmlEdge`, `SysmlAnnotation`, `SemanticIndex`) alongside the +internal build/resolve implementation of the semantic loading pipeline (`AstBuilder`, +`SymbolTable`, `ReferenceResolver`, `SupertypeWalker`). It contains nine units: `AstBuilder`, +`SymbolTable`, `ReferenceResolver`, `SupertypeWalker`, `SysmlNode`, `SysmlMetadataNode`, +`SysmlEdge`, `SemanticIndex`, and `SysmlAnnotation`. #### Interfaces @@ -26,14 +26,14 @@ implementation of the semantic loading pipeline (`AstBuilder`, `SymbolTable`, the symbol dictionary. Duplicate names are silently ignored. **`ReferenceResolver.ResolveAll(IEnumerable<(string, SysmlNode?)>)`**: Runs import-cycle detection -and supertype/typing/import reference resolution over all loaded file roots. +and supertype/typing/metadata-type/import reference resolution over all loaded file roots. - *Type*: In-process .NET internal method. - *Role*: Provider. - *Contract*: Accepts a list of `(FilePath, Root)` pairs; emits Warning diagnostics for - unresolved supertype, typing, and import references and for circular import chains; attaches - resolved `SysmlEdge` entries to each node's `ResolvedEdges`; returns a `SemanticIndex` over - all resolved edges. + unresolved supertype, typing, metadata-type, and import references and for circular import + chains; attaches resolved `SysmlEdge` entries to each node's `ResolvedEdges`; returns a + `SemanticIndex` over all resolved edges. **`SupertypeWalker.WalkAll()`**: Traverses all specialization chains to detect cyclic specialization. @@ -55,12 +55,13 @@ over resolved edges. | Unit | Responsibility | | --- | --- | -| `AstBuilder` | Visits ANTLR4 CST; builds typed AST nodes with qualified names and supertype lists | +| `AstBuilder` | Visits ANTLR4 CST; builds typed AST nodes, metadata children, and raw view-filter text | | `SymbolTable` | Registry mapping fully-qualified names to their AST nodes | -| `ReferenceResolver` | Resolves supertype/typing/redefinition/import/satisfy/verify/allocate/connect/transition | +| `ReferenceResolver` | Resolves supertype, typing, redefinition, metadata-type, import, and other references | | `SupertypeWalker` | Walks specialization chains; detects cyclic specialization | -| `SysmlNode` | Public abstract base record (and subtypes) modeling one parsed AST element | -| `SysmlEdge` | Public record modeling one resolved reference (Supertype/Typing/Import/Satisfy/Verify/Allocate/etc.) | +| `SysmlNode` | Public abstract base record (and core subtypes) modeling one parsed AST element | +| `SysmlMetadataNode` | Public node type modeling one applied metadata annotation and its captured values | +| `SysmlEdge` | Public record modeling one resolved reference (Supertype, Typing, MetadataType, etc.) | | `SemanticIndex` | Public reverse-lookup index over resolved `SysmlEdge` instances | | `SysmlAnnotation` | Public record modeling one captured `comment`/`doc` annotation (Comment/Documentation) | @@ -69,6 +70,6 @@ Interaction sequence: 1. `WorkspaceLoader` creates one `AstBuilder` per file and calls `Build(rootNamespaceContext)`. 2. The returned `SysmlPackageNode` root is passed to `SymbolTable.RegisterAll`. 3. After all files are registered, `ReferenceResolver.ResolveAll` traverses all user-file AST - roots, attaches `SysmlEdge` entries to each node's `ResolvedEdges`, and returns a - `SemanticIndex` over all resolved edges. + roots, attaches `SysmlEdge` entries to each node's `ResolvedEdges` (including metadata-type + edges on `SysmlMetadataNode` children), and returns a `SemanticIndex` over all resolved edges. 4. Finally, `SupertypeWalker.WalkAll` iterates over all symbols in the table. diff --git a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md index c1e776ae..b8edb179 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -80,8 +80,10 @@ corpus fixture `1c-PartsTreeRedefinition.sysml`'s `part vehicle1_c1 :> vehicle1 grammar alternatives of `annotatingElement` (`comment | documentation | textualRepresentation | metadataFeature`) and returns a private `AnnotationCapture` sentinel node wrapping a `SysmlAnnotation` built from `ExtractCommentText(REGULAR_COMMENT())`. `textualRepresentation` -and `metadataFeature` are unhandled (falls through to `base.VisitAnnotatingElement`, returning -`null`, unchanged from prior behavior). +remains unhandled, but `metadataFeature` is now captured as a first-class `SysmlMetadataNode` +child via `BuildMetadataNode`: it records the annotation type reference and any directly-assigned +scalar literal attributes (Boolean/Number/String), preserving unsupported value expressions as raw +text with `MetadataAttributeValueKind.Unsupported`. `ExtractCommentText(ITerminalNode?)` strips the `/*`/`//*` opening delimiter and trailing `*/` closing delimiter from a `REGULAR_COMMENT` token's raw text, preserving all interior @@ -123,8 +125,8 @@ absent. `ExtractViewRenderAndFilter` helper (see below) to populate `RenderTargetName` and `FilterExpressionText`. `VisitViewUsage` builds a `SysmlViewNode` for named `view` usages (the only body form that may additionally contain `expose` members) the same way, plus -`ExtractExposedNames` to populate `ExposedNames`. Unnamed view usages are skipped (no declared -name), mirroring the existing anonymous-element convention. +`ExtractExposedNames` to populate `ExposedNames` and `ExposeBracketFilterTexts`. Unnamed view +usages are skipped (no declared name), mirroring the existing anonymous-element convention. **`VisitViewUsage` is an intentional capability addition, not merely an `expose`-capture prerequisite.** Before this override existed, named `view Name { ... }` usages were silently @@ -149,13 +151,17 @@ appears (a defensive tie-break, not a validated SysML constraint). `ExtractRende follows the same two-form fallback pattern `VisitSatisfyRequirementUsage` uses: the direct reference form (`ownedReferenceSubsetting()`), falling back to the typed-placeholder form's feature typing (`ExtractFeatureTyping`), falling back to the raw usage text. The filter -expression's raw source text is taken verbatim from -`elementFilterMember().ownedExpression()?.GetText()` — never evaluated. +expression's raw source text is reconstructed with `GetOriginalText(...)`, preserving inter-token +whitespace so the Filtering subsystem can re-lex it faithfully rather than receiving +`RuleContext.GetText()`'s concatenated token stream. `ExtractExposedNames(IEnumerable bodyItems)` collects the raw reference text of every `expose ;` member in source order, reusing the shared `ExtractImportTarget` helper (see below) against each `expose` member's wrapped `namespaceImport()`/ -`membershipImport()` — the identical grammar shape `import` uses. +`membershipImport()` — the identical grammar shape `import` uses. When an `expose` member uses the +dominant corpus form `qualifiedName::**[]`, the same helper also returns the bracketed +filter expression's original source text so `SysmlViewNode.ExposeBracketFilterTexts` can preserve +it as Phase 1 capture-only data. `ExtractImportTarget(NamespaceImportContext?, MembershipImportContext?)` is a shared helper extracted from `VisitImportRule`'s previously inline logic, returning the extracted diff --git a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md index 5d019336..a532e3cd 100644 --- a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md @@ -3,8 +3,8 @@ ##### Overview `SysmlNode` is the abstract base class for all SysML/KerML AST nodes. Concrete subtypes represent -packages, definitions, features, imports, views, viewpoints, connections, transitions, and -requirement-satisfaction usages. +packages, definitions, features, imports, applied metadata annotations, views, viewpoints, +connections, transitions, and requirement-satisfaction usages. ##### Class Hierarchy @@ -15,7 +15,8 @@ requirement-satisfaction usages. | `SysmlDefinitionNode` | Definition element (part def, attribute def, etc.); adds DefinitionKeyword | | `SysmlFeatureNode` | Feature/usage element | | `SysmlImportNode` | Import declaration; adds ImportedNamespace, IsWildcard | -| `SysmlViewNode` | View definition; adds RenderTargetName, ExposedNames, FilterExpressionText | +| `SysmlMetadataNode` | Applied metadata annotation; adds TypeReference and Attributes | +| `SysmlViewNode` | View definition; adds RenderTargetName, ExposedNames, and filter-expression fields | | `SysmlViewpointNode` | Viewpoint definition | | `SysmlConnectionNode` | Connection/binding/allocation usage; adds ConnectionKeyword, EndpointA, EndpointB | | `SysmlTransitionNode` | State transition; adds Source, Target, Guard | @@ -59,6 +60,8 @@ There are no behavioral methods beyond the inherited `object` members. `SysmlImp - `ImportedNamespace` — the target namespace string extracted by `ReferenceResolver`. - `IsWildcard` — `true` if the import ends with `::*`. +- `BracketFilterExpressionText` — the raw source text of an `import`/`expose` bracket filter + (`::**[]`), or null when absent. Preserved as capture-only Phase 1 data. `SysmlDefinitionNode` adds: @@ -109,6 +112,14 @@ There are no behavioral methods beyond the inherited `object` members. `SysmlImp - `SubjectName` — the raw reference text of the satisfying subject (from the `by ` clause), or null when no `by` clause is present. +`SysmlMetadataNode` adds: + +- `TypeReference` — the raw reference text of the annotating metadata type. Resolved by + `ReferenceResolver` into a `SysmlEdgeKind.MetadataType` edge. +- `Attributes` — the ordered list of captured `MetadataAttributeValue` entries assigned within the + annotation body. Supported Phase 1 scalar literals are preserved as typed values; unsupported + value-expression shapes remain raw text only. + `SysmlViewNode` adds: - `RenderTargetName` — the raw reference text of the view's `render ;` member (the @@ -129,11 +140,14 @@ There are no behavioral methods beyond the inherited `object` members. `SysmlImp `SysmlEdgeKind.Expose` edge, or an unresolved-reference diagnostic (and no edge) for that entry. This is the sole field `GeneralViewLayoutStrategy` uses to scope a rendered diagram. - `FilterExpressionText` — the raw source text of the view's `filter [];` member's - bracketed expression, or null when absent. Captured verbatim by `AstBuilder` - (`elementFilterMember().ownedExpression().GetText()`) and never evaluated or inspected by - `ReferenceResolver` — full filter expression evaluation is deferred future work (see - ROADMAP.md). `GeneralViewLayoutStrategy` emits a "parsed but not yet evaluated" warning - whenever this is non-null. + bracketed expression, or null when absent. Captured verbatim by `AstBuilder` (using the + original token spacing, not `RuleContext.GetText()`'s whitespace-stripped form) and never + inspected by `ReferenceResolver`. The Core Filtering subsystem parses and evaluates this raw + text later when `GeneralViewLayoutStrategy` renders the view. +- `ExposeBracketFilterTexts` — the raw source text of each bracketed + `expose ::**[]` filter expression, in source order. Phase 1 captures these + expressions but does not evaluate them; `GeneralViewLayoutStrategy` surfaces a dedicated + warning when this list is non-empty. ##### Error Handling @@ -154,16 +168,17 @@ elements are filtered out by `AstBuilder` before a node is constructed. instances from `satisfyRequirementUsage` via `VisitSatisfyRequirementUsage`, and the `"allocation"` `SysmlConnectionNode` variant via `VisitAllocationUsage`; builds `SysmlViewNode` instances (setting `RenderTargetName`/`FilterExpressionText`, and additionally `ExposedNames` - for usages) from both `VisitViewDefinition` (`view def`) and `VisitViewUsage` (`view`, the - only form that can carry `expose`). + and `ExposeBracketFilterTexts` for usages) from both `VisitViewDefinition` (`view def`) and + `VisitViewUsage` (`view`, the only form that can carry `expose`); builds `SysmlMetadataNode` + children from `metadataFeature` annotations. - `SymbolTable` — traverses the node hierarchy via `Children`; reads `QualifiedName`. - `ReferenceResolver` — reads `SupertypeNames`, `FeatureTyping`, `RedefinedFeatureName`, `ImportedNames`, `VerifiedRequirementNames`, `Children`; checks for `SysmlImportNode`, `SysmlSatisfyNode`, the `"allocation"` `SysmlConnectionNode` variant, the `"connection"`/`"message"` `SysmlConnectionNode` variants, `SysmlTransitionNode`, and `SysmlViewNode` (reading - `ExposedNames`; `RenderTargetName`/`FilterExpressionText` are never read); writes - `ResolvedEdges` after resolving references (in two passes — - supertype/typing/redefinition/import/satisfy/verify/allocate/expose, then feature-chain - connect/transition). + `ExposedNames`; `RenderTargetName`/`FilterExpressionText`/`ExposeBracketFilterTexts` are never + read), and `SysmlMetadataNode` (reading `TypeReference`); writes `ResolvedEdges` after + resolving references (in two passes — supertype/typing/redefinition/metadata-type/import/ + satisfy/verify/allocate/expose, then feature-chain connect/transition). - `SupertypeWalker` — reads `SupertypeNames` on each node retrieved from `SymbolTable`. diff --git a/docs/reqstream/sysml2-tools-core/filtering.yaml b/docs/reqstream/sysml2-tools-core/filtering.yaml new file mode 100644 index 00000000..88d5da57 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/filtering.yaml @@ -0,0 +1,35 @@ +--- +# Filtering Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the SysML2Tools Filtering subsystem +# - The Filtering subsystem parses and evaluates the Phase 1 subset of standalone view +# `filter [];` expressions over semantic-model candidate elements +# - Requirements describe observable filtering behavior, not parser internals + +sections: + - title: Filtering Subsystem Requirements + requirements: + - id: SysML2Tools-Core-Filtering-StandaloneViewFilterEvaluation + title: >- + The Filtering subsystem shall parse and evaluate a standalone view `filter [];` + expression over a caller-supplied candidate set, matching candidates by metadata + classification tests, boolean composition, and metadata-attribute reads for the Phase 1 + construct subset while surfacing explicit diagnostics for malformed or unsupported + expressions without throwing. + justification: | + Standalone view filters are the user-visible mechanism for narrowing a rendered scope by + metadata. The subsystem exists to turn the raw expression text captured by AstBuilder + into a matched subset of candidate elements while degrading safely when the expression is + outside the supported Phase 1 boundary. + children: + - SysML2Tools-Core-Filtering-FilterExpressionEvaluator-ClassificationTests + - SysML2Tools-Core-Filtering-FilterExpressionEvaluator-BooleanConnectives + - SysML2Tools-Core-Filtering-FilterExpressionEvaluator-AttributeReads + - SysML2Tools-Core-Filtering-FilterExpressionEvaluator-UnsupportedConstructDiagnostics + - SysML2Tools-Core-Filtering-FilterExpressionEvaluator-RoundTripPrettyPrinting + tests: + - DiagramRenderer_RenderWorkspace_SafetyPartsView_FiltersToAnnotatedParts + - DiagramRenderer_RenderWorkspace_MandatorySafetyPartsView_FiltersToMandatoryPart + - GeneralViewLayoutStrategy_BuildLayout_FilterExpressionMatchesNothing_RendersEmpty + - GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning diff --git a/docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml b/docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml new file mode 100644 index 00000000..b06126d7 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml @@ -0,0 +1,99 @@ +--- +# cspell:ignore istype hastype reparses +# FilterExpressionEvaluator Unit Requirements +# +# PURPOSE: +# - Define requirements for the FilterExpressionEvaluator unit +# - This unit's implementation spans the filter-expression AST, parser adaptation, and evaluator +# used by standalone view `filter [];` statements +# - Requirements describe observable parse/evaluate behavior, not CST-walking internals + +sections: + - title: FilterExpressionEvaluator Unit Requirements + requirements: + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator-ClassificationTests + title: >- + FilterExpressionEvaluator shall parse and evaluate metadata classification tests + (`@Type`, `@Pkg::Type`) by matching candidates that carry the referenced metadata + annotation type. + justification: | + Classification tests are the simplest and most common filter predicate in Phase 1. They + let a view select elements based on the presence of a metadata annotation regardless of + whether the filter author spelled the type reference as a simple or qualified name. + tests: + - Parse_ClassificationTest_ReturnsClassificationTestExpression + - Parse_QualifiedClassificationTest_PreservesQualifiedName + - Evaluate_ClassificationTest_MatchesOnlyAnnotatedCandidates + - Evaluate_QualifiedClassificationTest_Matches + - Evaluate_ClassificationTestNoMatch_ReturnsEmpty + + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator-BooleanConnectives + title: >- + FilterExpressionEvaluator shall support boolean composition of Phase 1 predicates via + `and`, `or`, `xor`, `not`, `&`, and `|`, preserving the author's operator spelling in the + canonical pretty-printed form. + justification: | + Real SysML view filters combine multiple metadata conditions. Supporting both keyword and + symbol spellings keeps the accepted syntax aligned with the grammar and preserves the + source author's chosen spelling when the expression is re-rendered. + tests: + - Parse_AndConnective_ReturnsBooleanExpression + - Parse_OrConnective_ReturnsBooleanExpression + - Parse_XorConnective_ReturnsBooleanExpression + - Parse_AmpSymbol_ReturnsAndWithSymbolSpelling + - Parse_PipeSymbol_ReturnsOrWithSymbolSpelling + - Parse_Not_ReturnsNotExpression + - Parse_Parenthesized_ReturnsInnerExpression + - Evaluate_Not_InvertsMatchSet + - Evaluate_And_MatchesIntersection + - Evaluate_Or_MatchesUnion + + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator-AttributeReads + title: >- + FilterExpressionEvaluator shall support `(as Type).attribute` reads both as bare boolean + predicates and as `==`/`!=` comparisons against scalar literals, treating an absent + matching annotation or attribute value as a non-match rather than an error. + justification: | + Metadata filtering needs to distinguish between the mere presence of an annotation and + the values assigned within it. Conservative false semantics for absent reads keep Phase 1 + evaluation deterministic and safe without inventing implicit default values. + tests: + - Parse_AttributeRead_ReturnsAttributeReadExpression + - Parse_AttributeReadEqualsBoolean_ReturnsComparisonExpression + - Parse_ClassificationTestAndAttributeRead_ReAssociatesDotOntoRightOperand + - Parse_AttributeReadNotEqualsString_ReturnsComparisonExpression + - Parse_AttributeReadEqualsNumber_ReturnsComparisonExpression + - Evaluate_BareAttributeRead_TrueOnlyWhenBooleanValueTrue + - Evaluate_ComparisonEqual_MatchesEqualValue + - Evaluate_ComparisonNotEqual_MatchesDifferingValue + - Evaluate_AttributeReadAbsent_NeverMatchesComparison + + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator-UnsupportedConstructDiagnostics + title: >- + FilterExpressionEvaluator shall report malformed syntax and every construct outside the + Phase 1 subset as explicit diagnostics and shall never throw while parsing or evaluating + them. + justification: | + Unsupported constructs are an expected Phase 1 boundary, not an exceptional condition. + Reporting them as diagnostics allows layout to fall back to the unfiltered scope with a + visible warning instead of crashing or silently dropping the filter. + tests: + - Parse_Istype_ReturnsUnsupportedConstructDiagnostic + - Parse_Hastype_ReturnsUnsupportedConstructDiagnostic + - Parse_All_ReturnsUnsupportedConstructDiagnostic + - Parse_Arithmetic_ReturnsUnsupportedConstructDiagnostic + - Parse_Conditional_ReturnsUnsupportedConstructDiagnostic + - Parse_GeneralFeatureChainNavigation_ReturnsUnsupportedConstructDiagnostic + - Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic + - Evaluate_UnknownCandidate_SkipsGracefully + + - id: SysML2Tools-Core-Filtering-FilterExpressionEvaluator-RoundTripPrettyPrinting + title: >- + Every supported Phase 1 filter-expression tree shall pretty-print to canonical SysML v2 + filter syntax whose re-parse yields a semantically-equivalent tree. + justification: | + Canonical pretty-printing keeps diagnostics, debugging output, and future persisted + filter-expression scenarios stable. Re-parsing the printed form proves the AST shape and + printer stay aligned with the accepted grammar subset. + tests: + - Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml index 9a435399..0cbfd25e 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml @@ -189,18 +189,31 @@ sections: - GeneralViewLayoutStrategy_BuildLayout_RenderTargetNameOnly_NoExposeEdges_RendersFullWorkspace - GeneralViewLayoutStrategy_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged - - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-UnevaluatedFilterWarning - title: >- - When a view's FilterExpressionText is non-null, GeneralViewLayoutStrategy shall emit a - diagnostic through the layout's Warnings channel stating that the view declares a - filter expression which is parsed but not yet evaluated, and shall continue rendering - the (unfiltered) resolved scope. - justification: | - Full `filter [];` expression evaluation is deferred future work (see ROADMAP.md). - Silently ignoring a declared filter would mislead a user into believing their diagram is - filtered when every element in the resolved scope is actually shown unfiltered; - surfacing an explicit warning through the existing layout-warnings channel makes this - limitation visible rather than a silent gap. + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-FilterEvaluation + title: >- + When a view's FilterExpressionText parses as a supported Phase 1 filter expression, + GeneralViewLayoutStrategy shall evaluate it against the already expose-scoped candidate + definitions and shall render only the matched subset, including an empty canvas when no + candidate matches. + justification: | + Standalone `filter [];` is the user-visible mechanism for narrowing a view by + metadata once the expose scope has been resolved. Applying the predicate after expose + scoping preserves the meaning of both statements: `expose` chooses the candidate scope, + then `filter` further narrows it. + tests: + - GeneralViewLayoutStrategy_BuildLayout_FilterExpressionMatchesNothing_RendersEmpty + - DiagramRenderer_RenderWorkspace_SafetyPartsView_FiltersToAnnotatedParts + - DiagramRenderer_RenderWorkspace_MandatorySafetyPartsView_FiltersToMandatoryPart + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-FilterFallbackWarning + title: >- + When a view's FilterExpressionText fails to parse or uses a construct outside the Phase 1 + subset, GeneralViewLayoutStrategy shall emit a warning stating that the filter expression + could not be evaluated and shall continue rendering the unfiltered resolved scope. + justification: | + Unsupported filter constructs are an expected Phase 1 boundary, not a crash condition. + Falling back to the unfiltered scope while surfacing a visible warning preserves a usable + diagram and makes the limitation explicit. tests: - GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml index 74fa042c..18fabdfa 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/layout-warnings.yaml @@ -34,13 +34,29 @@ sections: - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings-UnevaluatedFilter title: >- When a view's filter expression text is non-null, LayoutWarnings shall produce a - warning naming the view and stating that its filter expression is parsed but not yet - evaluated; when null, LayoutWarnings shall produce no warning. + warning naming the view and stating that the filter expression could not be evaluated, + optionally appending the caller-supplied reason; when null, LayoutWarnings shall produce + no warning. justification: | - Full `filter [];` expression evaluation is deferred future work (see - ROADMAP.md). Silently ignoring a declared filter would mislead a user into believing - their diagram is filtered when every element in the resolved scope actually renders - unfiltered; this warning makes the limitation visible. + A standalone `filter [];` expression now attempts real evaluation, so the warning + path is reserved for parse failures and unsupported Phase 1 constructs. Surfacing the + reason string helps the user understand whether the fallback came from malformed syntax + or an intentionally unsupported construct. tests: - ForUnevaluatedFilter_NullText_ReturnsEmpty - ForUnevaluatedFilter_NonNullText_ReturnsNotYetEvaluatedWarning + - ForUnevaluatedFilter_WithReason_IncludesReason + + - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings-UnevaluatedExposeBracketFilter + title: >- + When a view declares one or more bracketed `expose ::**[]` filters, + LayoutWarnings shall produce a warning naming the view and stating that the bracket + filter expressions are parsed but not yet evaluated; when none are present, + LayoutWarnings shall produce no warning. + justification: | + Phase 1 intentionally defers bracket-filter evaluation while still capturing the raw + expression text. A distinct warning prevents users from confusing that deferred capability + with the now-supported standalone `filter [];` statement. + tests: + - ForUnevaluatedExposeBracketFilter_Empty_ReturnsEmpty + - ForUnevaluatedExposeBracketFilter_NonEmpty_ReturnsWarning diff --git a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml index 6d3cf0a4..1068be78 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -137,3 +137,28 @@ sections: - WorkspaceLoader_LoadAsync_ColonGtGtOperator_CapturesRedefinedFeatureName - WorkspaceLoader_LoadAsync_QualifiedRedefinition_CapturesRawText - WorkspaceLoader_LoadAsync_NoRedefinition_RedefinedFeatureNameIsNull + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-MetadataAnnotations + title: >- + AstBuilder shall capture each applied metadata annotation nested in an element body as a + SysmlMetadataNode child carrying the annotating type reference and any supported literal + attribute assignments in source order. + justification: | + Phase 1 filter evaluation operates over metadata annotations attached to definitions and + features. Capturing those annotations during AST construction is the prerequisite for + later reference resolution and metadata-based filtering. + tests: + - AstBuilder_BareMetadataAnnotation_CapturesMetadataNode + - AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ExposeBracketFilterText + title: >- + AstBuilder shall capture the raw expression text of each bracketed + `expose ::**[]` member on the corresponding SysmlViewNode's + ExposeBracketFilterTexts property without evaluating the expression. + justification: | + Phase 1 defers bracket-filter evaluation but must preserve the expression text so layout + can surface a distinct "parsed but not yet evaluated" warning and Phase 2 can add real + evaluation without changing the AST capture path. + tests: + - AstBuilder_ExposeBracketFilter_CapturesRawText diff --git a/docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml b/docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml new file mode 100644 index 00000000..8735c647 --- /dev/null +++ b/docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml @@ -0,0 +1,60 @@ +--- +# SysmlMetadataNode Unit Requirements +# +# PURPOSE: +# - Define requirements for the SysmlMetadataNode unit +# - SysmlMetadataNode captures an applied metadata annotation on a semantic-model element +# - Requirements describe the observable captured data and resolution behavior, not parser internals + +sections: + - title: SysmlMetadataNode Unit Requirements + requirements: + - id: SysML2Tools-Language-Semantic-Model-SysmlMetadataNode-CaptureForms + title: >- + SysmlMetadataNode shall capture an applied metadata annotation's raw type reference and + shall represent bare and body-bearing annotation forms as a child of the annotated + element, with an empty Attributes list when the annotation assigns no attribute values. + justification: | + Metadata annotations are first-class semantic content, not free-text documentation. + Capturing them as child nodes preserves both the annotating type reference and the fact + that the owning element was annotated at all, which Phase 1 filter-expression + evaluation depends on. + tests: + - AstBuilder_BareMetadataAnnotation_CapturesMetadataNode + + - id: SysML2Tools-Language-Semantic-Model-SysmlMetadataNode-LiteralAttributes + title: >- + SysmlMetadataNode shall capture scalar literal attribute assignments on a metadata + annotation as ordered MetadataAttributeValue entries, preserving the attribute name, + raw value text, literal kind, and parsed scalar value for supported boolean, number, + and string literals. + justification: | + Phase 1 filter evaluation reads metadata attributes by name and compares them against + scalar literals. The semantic model therefore needs the assigned values preserved as + structured data rather than only as raw annotation text. + tests: + - AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue + - Evaluate_ComparisonEqual_MatchesEqualValue + + - id: SysML2Tools-Language-Semantic-Model-SysmlMetadataNode-MetadataTypeResolution + title: >- + ReferenceResolver shall resolve a SysmlMetadataNode's TypeReference against an in-scope + metadata definition and record the result as a MetadataType-kind edge on the metadata + node. + justification: | + Filter classification tests match by metadata type. Resolving the raw type reference to a + concrete metadata definition makes metadata annotations comparable across files, + namespaces, and imported scopes. + tests: + - AstBuilder_MetadataAnnotation_ResolvesTypeReference + + - id: SysML2Tools-Language-Semantic-Model-SysmlMetadataNode-UnresolvedTypeWarning + title: >- + When a SysmlMetadataNode's TypeReference cannot be resolved, ReferenceResolver shall + emit an "Unresolved reference" Warning diagnostic and shall record no MetadataType edge. + justification: | + An unresolved metadata annotation type is model incompleteness that callers need to see, + but it must not prevent semantic loading from completing. The warning mirrors every + other reference-resolution failure path in the semantic model. + tests: + - AstBuilder_MetadataAnnotation_UnresolvedType_ProducesWarning diff --git a/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml b/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml index 0ecc04b9..e6d6fe3e 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml @@ -20,8 +20,8 @@ sections: - id: SysML2Tools-Language-Semantic-Model-SysmlNode-Types title: >- The SysmlNode hierarchy shall include SysmlPackageNode, SysmlDefinitionNode, - SysmlFeatureNode, SysmlImportNode, SysmlViewNode, SysmlViewpointNode, - SysmlConnectionNode, and SysmlTransitionNode. + SysmlFeatureNode, SysmlImportNode, SysmlMetadataNode, SysmlViewNode, + SysmlViewpointNode, SysmlConnectionNode, and SysmlTransitionNode. justification: | Separate node types for packages, definitions, features, imports, views, viewpoints, connections, and transitions allow future analysis passes to dispatch @@ -59,12 +59,23 @@ sections: [];` member, or null when absent. justification: | Making a view's declared filter expression user-observable data is required so - callers can observe it as raw source text; full filter expression evaluation is - deferred future work and is out of scope for this field. + callers can observe it as raw source text and pass it to the Core Filtering subsystem; + this semantic-model field remains capture-only and does not itself perform evaluation. tests: - WorkspaceLoader_LoadAsync_ViewFilterExpression_CapturesTextVerbatimNoEdge - WorkspaceLoader_LoadAsync_ViewEmptyBody_AllNewFieldsNullOrEmpty + - id: SysML2Tools-Language-Semantic-Model-SysmlNode-ViewExposeBracketFilters + title: >- + SysmlViewNode shall expose ExposeBracketFilterTexts: the raw source text of each + bracketed `expose ::**[]` filter expression, empty when absent. + justification: | + Phase 1 defers bracket-filter evaluation but must preserve the captured expressions so + layout can surface a distinct warning and later phases can add real evaluation without + changing the semantic model. + tests: + - AstBuilder_ExposeBracketFilter_CapturesRawText + - id: SysML2Tools-Language-Semantic-Model-SysmlNode-RedefinedFeatureName title: >- SysmlFeatureNode shall expose RedefinedFeatureName: the raw reference text of its diff --git a/docs/verification/sysml2-tools-core.md b/docs/verification/sysml2-tools-core.md index 4d89f72e..269eb112 100644 --- a/docs/verification/sysml2-tools-core.md +++ b/docs/verification/sysml2-tools-core.md @@ -3,11 +3,11 @@ ## Verification Approach System-level verification for the `DemaConsulting.SysML2Tools` core library uses unit tests -in `DemaConsulting.SysML2Tools.Tests`. Tests exercise the Layout and Rendering pipeline via -`DiagramRenderer` and `GeneralViewLayoutStrategy`, along with the shared -`ExposeScopeResolver`-based expose-scoping path exercised by all seven layout strategies. The -xUnit v3 framework discovers and runs all test methods; results are captured in TRX files -consumed by ReqStream. +in `DemaConsulting.SysML2Tools.Tests`. Tests exercise the Filtering, Layout, and Rendering +pipeline via `FilterExpressionParser`, `FilterExpressionEvaluator`, `DiagramRenderer`, and +`GeneralViewLayoutStrategy`, along with the shared `ExposeScopeResolver`-based expose-scoping +path exercised by all seven layout strategies. The xUnit v3 framework discovers and runs all +test methods; results are captured in TRX files consumed by ReqStream. ## Test Environment @@ -18,6 +18,8 @@ SDK installation. ## Acceptance Criteria - All unit tests pass with zero failures across all three target frameworks. +- `FilterExpressionParser` and `FilterExpressionEvaluator` correctly narrow candidate elements for + the supported Phase 1 standalone view-filter subset and degrade safely on unsupported input. - `DiagramRenderer.RenderWorkspace` correctly renders views declared in a `SysmlWorkspace`. - `GeneralViewLayoutStrategy` produces a valid `LayoutTree` for a given `ViewContext`. - Every layout strategy honors a view's resolved `expose` scope via the shared @@ -28,5 +30,7 @@ SDK installation. Primary acceptance evidence is provided by: +- `FilterExpressionParserTests` / `FilterExpressionEvaluatorTests` — direct filtering subsystem + tests. - `RenderIntegrationTests` — end-to-end rendering tests with stdlib seed workspace. - `GeneralViewLayoutStrategyTests` — layout algorithm tests for general view diagrams. diff --git a/docs/verification/sysml2-tools-core/filtering.md b/docs/verification/sysml2-tools-core/filtering.md new file mode 100644 index 00000000..08eb4382 --- /dev/null +++ b/docs/verification/sysml2-tools-core/filtering.md @@ -0,0 +1,61 @@ + + +## DemaConsulting.SysML2Tools — Filtering Subsystem Verification + +### Verification Approach + +The Filtering subsystem is verified by focused parser/evaluator unit tests in +`DemaConsulting.SysML2Tools.Tests.Filtering` plus end-to-end rendering tests in +`RenderIntegrationTests` and layout integration tests in `GeneralViewLayoutStrategyTests`. The +unit tests exercise the parser and evaluator directly against inline source text and synthetic +candidate sets; the integration tests confirm that a view's captured `FilterExpressionText` really +narrows the rendered scope or, on failure, falls back to the unfiltered scope with a warning. + +### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. Parser tests operate entirely +in-memory. Evaluator and integration tests load temporary or repository-owned `.sysml` models +through `WorkspaceLoader` with the seeded standard library. No external services or network access +are required. + +### Acceptance Criteria + +- All filtering parser, evaluator, layout, and rendering tests pass with zero failures across all + three target frameworks. +- A standalone `filter [];` expression can narrow a candidate set by metadata annotation + presence alone. +- Boolean composition and metadata-attribute reads can combine to narrow the rendered scope to a + strict subset of annotated candidates. +- A supported filter that matches no candidates renders an empty General View rather than falling + back to the unfiltered scope. +- Malformed or unsupported filter expressions surface explicit diagnostics and cause layout to + render the unfiltered resolved scope with a warning instead of throwing. +- Canonical pretty-printing of supported filter expressions round-trips through the parser. + +### Test Scenarios + +- `Parse_ClassificationTest_ReturnsClassificationTestExpression` — bare metadata classification + test parses successfully +- `Parse_AttributeReadEqualsBoolean_ReturnsComparisonExpression` — attribute-read comparison + parses successfully +- `Parse_ClassificationTestAndAttributeRead_ReAssociatesDotOntoRightOperand` — the DOT/boolean + grammar quirk is repaired into the intended AST +- `Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic` — malformed syntax reports diagnostics + without throwing +- `Evaluate_ClassificationTest_MatchesOnlyAnnotatedCandidates` — metadata classification narrows + to annotated candidates +- `Evaluate_And_MatchesIntersection` — boolean conjunction yields the intersection of matches +- `Evaluate_ComparisonEqual_MatchesEqualValue` — string-valued metadata attribute comparison + matches correctly +- `Evaluate_AttributeReadAbsent_NeverMatchesComparison` — missing metadata attributes evaluate + conservatively as false +- `Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree` — canonical pretty-printing + re-parses to an equivalent tree +- `GeneralViewLayoutStrategy_BuildLayout_FilterExpressionMatchesNothing_RendersEmpty` — + a supported filter can narrow the General View to zero boxes +- `GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning` — + unsupported filter text falls back to the unfiltered scope with a warning +- `DiagramRenderer_RenderWorkspace_SafetyPartsView_FiltersToAnnotatedParts` — end-to-end + rendering includes only `@Safety`-annotated definitions +- `DiagramRenderer_RenderWorkspace_MandatorySafetyPartsView_FiltersToMandatoryPart` — + end-to-end rendering combines classification and attribute-read predicates diff --git a/docs/verification/sysml2-tools-core/filtering/filter-expression-evaluator.md b/docs/verification/sysml2-tools-core/filtering/filter-expression-evaluator.md new file mode 100644 index 00000000..a62b1d09 --- /dev/null +++ b/docs/verification/sysml2-tools-core/filtering/filter-expression-evaluator.md @@ -0,0 +1,89 @@ + + +### FilterExpressionEvaluator Verification + +#### Verification Approach + +`FilterExpressionEvaluator` is verified through direct unit tests in `FilterExpressionParserTests` +and `FilterExpressionEvaluatorTests`. Parser tests exercise the AST builder, unsupported-construct +reporting, malformed-syntax handling, and canonical pretty-print round-tripping. Evaluator tests +load a small semantic workspace with metadata annotations and verify candidate matching over the +parsed AST. Integration evidence from `GeneralViewLayoutStrategyTests` and `RenderIntegrationTests` +confirms the parser/evaluator behavior composes correctly into layout and rendering. + +#### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. Parser tests are fully +in-memory. Evaluator tests create temporary `.sysml` files, load them through `WorkspaceLoader`, +and delete them after each run. No external services or configuration are required beyond the .NET +SDK and the repository's committed SysML fixtures. + +#### Acceptance Criteria + +- All parser and evaluator tests pass with zero failures across all three target frameworks. +- Classification tests match candidates carrying the referenced metadata annotation. +- Boolean connectives and parentheses preserve the intended logical grouping. +- `(as Type).attribute` reads work both as bare boolean predicates and as scalar comparisons. +- Absent metadata attributes evaluate conservatively as false. +- Unsupported constructs and malformed syntax report diagnostics and never throw. +- Pretty-printing a supported AST re-parses to an equivalent tree. + +#### Requirement-to-Test Mapping + +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-ClassificationTests` + - `Parse_ClassificationTest_ReturnsClassificationTestExpression` + - `Parse_QualifiedClassificationTest_PreservesQualifiedName` + - `Evaluate_ClassificationTest_MatchesOnlyAnnotatedCandidates` + - `Evaluate_QualifiedClassificationTest_Matches` + - `Evaluate_ClassificationTestNoMatch_ReturnsEmpty` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-BooleanConnectives` + - `Parse_AndConnective_ReturnsBooleanExpression` + - `Parse_OrConnective_ReturnsBooleanExpression` + - `Parse_XorConnective_ReturnsBooleanExpression` + - `Parse_AmpSymbol_ReturnsAndWithSymbolSpelling` + - `Parse_PipeSymbol_ReturnsOrWithSymbolSpelling` + - `Parse_Not_ReturnsNotExpression` + - `Parse_Parenthesized_ReturnsInnerExpression` + - `Evaluate_Not_InvertsMatchSet` + - `Evaluate_And_MatchesIntersection` + - `Evaluate_Or_MatchesUnion` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-AttributeReads` + - `Parse_AttributeRead_ReturnsAttributeReadExpression` + - `Parse_AttributeReadEqualsBoolean_ReturnsComparisonExpression` + - `Parse_ClassificationTestAndAttributeRead_ReAssociatesDotOntoRightOperand` + - `Parse_AttributeReadNotEqualsString_ReturnsComparisonExpression` + - `Parse_AttributeReadEqualsNumber_ReturnsComparisonExpression` + - `Evaluate_BareAttributeRead_TrueOnlyWhenBooleanValueTrue` + - `Evaluate_ComparisonEqual_MatchesEqualValue` + - `Evaluate_ComparisonNotEqual_MatchesDifferingValue` + - `Evaluate_AttributeReadAbsent_NeverMatchesComparison` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-UnsupportedConstructDiagnostics` + - `Parse_Istype_ReturnsUnsupportedConstructDiagnostic` + - `Parse_Hastype_ReturnsUnsupportedConstructDiagnostic` + - `Parse_All_ReturnsUnsupportedConstructDiagnostic` + - `Parse_Arithmetic_ReturnsUnsupportedConstructDiagnostic` + - `Parse_Conditional_ReturnsUnsupportedConstructDiagnostic` + - `Parse_GeneralFeatureChainNavigation_ReturnsUnsupportedConstructDiagnostic` + - `Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic` + - `Evaluate_UnknownCandidate_SkipsGracefully` +- `SysML2Tools-Core-Filtering-FilterExpressionEvaluator-RoundTripPrettyPrinting` + - `Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree` + +#### Test Scenarios + +- `Parse_ClassificationTest_ReturnsClassificationTestExpression` — bare `@Type` filter parses to + a classification-test node +- `Parse_ClassificationTestAndAttributeRead_ReAssociatesDotOntoRightOperand` — DOT is + re-associated onto the boolean chain's rightmost operand +- `Parse_MalformedSyntax_NeverThrows_ReturnsDiagnostic` — syntax errors produce diagnostics + instead of exceptions +- `Evaluate_ClassificationTest_MatchesOnlyAnnotatedCandidates` — only candidates carrying the + requested metadata annotation match +- `Evaluate_BareAttributeRead_TrueOnlyWhenBooleanValueTrue` — bare attribute reads are true only + for Boolean `true` values +- `Evaluate_ComparisonNotEqual_MatchesDifferingValue` — `!=` comparisons match differing captured + values +- `Evaluate_UnknownCandidate_SkipsGracefully` — missing candidate declarations are ignored without + failure +- `Parse_RoundTrip_PrettyPrintedTextReparsesToEquivalentTree` — pretty-printer output remains + accepted by the parser diff --git a/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index 0161b700..13647df9 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -48,9 +48,11 @@ configuration are required beyond a standard .NET SDK installation. - A view whose resolved `Expose` edge names a feature usage (not a definition) still renders that usage's type's containment subtree, by additionally resolving the usage's own `Typing` edge — the fix for the usage-vs-definition containment gap. -- A view whose `ViewContext.ViewNode` carries a non-null `FilterExpressionText` emits the "parsed - but not yet evaluated" warning through `LayoutTree.Warnings`, while still rendering the resolved - (unfiltered) scope. +- A view whose `ViewContext.ViewNode` carries a supported `FilterExpressionText` narrows the + already expose-scoped candidate definitions to the matched subset, including the empty-set case. +- A view whose `ViewContext.ViewNode` carries an unsupported or malformed `FilterExpressionText` + emits a "could not be evaluated" warning through `LayoutTree.Warnings`, while still rendering + the resolved (unfiltered) scope. - A view with a `null` `ViewContext.ViewNode` (the `--auto` synthesized-view path, and the pre-scoping-change 2-argument `ViewContext` construction used throughout the rest of this test file) renders every non-stdlib definition in the workspace, unchanged from before this feature — @@ -117,7 +119,10 @@ configuration are required beyond a standard .NET SDK installation. A resolved `Expose` edge naming a feature usage resolves through the usage's `Typing` edge to include its type's containment subtree - `GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning`: - A non-null `FilterExpressionText` emits the "parsed but not yet evaluated" warning + Unsupported filter text emits the "could not be evaluated" warning while the unfiltered scope + still renders +- `GeneralViewLayoutStrategy_BuildLayout_FilterExpressionMatchesNothing_RendersEmpty`: + A supported filter expression that matches no candidates narrows the diagram to an empty canvas - `GeneralViewLayoutStrategy_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged`: A `null` `ViewNode` (`--auto`/default) renders every definition, unchanged (regression guard) - `GeneralViewLayoutStrategy_BuildLayout_BareNameRedefinition_ProducesHollowTriangleCrossbarEdge`: diff --git a/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md b/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md index 012e5fa9..ab785446 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md +++ b/docs/verification/sysml2-tools-core/layout/internal/layout-warnings.md @@ -2,10 +2,10 @@ ##### Verification Approach -`LayoutWarnings` is verified through unit tests in `LayoutWarningsTests` that call `ForCrossings` -with a view name and a crossing count, and `ForUnevaluatedFilter` with a view name and a filter -expression text, asserting on the returned lists in each case. The unit is a pure function, so no -mocking is required. +`LayoutWarnings` is verified through unit tests in `LayoutWarningsTests` that call +`ForCrossings`, `ForUnevaluatedFilter`, and `ForUnevaluatedExposeBracketFilter` with controlled +inputs, asserting on the returned lists in each case. The unit is a pure function, so no mocking +is required. ##### Test Environment @@ -20,7 +20,11 @@ configuration are required beyond a standard .NET SDK installation. - A count greater than one yields a single plural-form warning reporting the count. - A null filter expression text yields no warning. - A non-null filter expression text yields a single warning naming the view and stating the - filter expression is parsed but not yet evaluated. + filter expression could not be evaluated. +- A supplied reason string is included in the standalone-filter warning. +- An empty bracket-filter list yields no warning. +- A non-empty bracket-filter list yields a single warning naming the view and reporting that the + bracket filters are parsed but not yet evaluated. ##### Test Scenarios @@ -31,3 +35,6 @@ configuration are required beyond a standard .NET SDK installation. | `ForCrossings_Many_ReturnsPluralWarning` | Multiple crossings yield a plural warning with the count | | `ForUnevaluatedFilter_NullText_ReturnsEmpty` | A null filter expression text yields an empty list | | `ForUnevaluatedFilter_NonNullText_ReturnsNotYetEvaluatedWarning` | Non-null filter yields a warning naming the view | +| `ForUnevaluatedFilter_WithReason_IncludesReason` | Supplied reason text is appended to the warning | +| `ForUnevaluatedExposeBracketFilter_Empty_ReturnsEmpty` | No bracket filters yields an empty list | +| `ForUnevaluatedExposeBracketFilter_NonEmpty_ReturnsWarning` | Bracket filters yield a single warning naming the view | diff --git a/docs/verification/sysml2-tools-language/semantic/model.md b/docs/verification/sysml2-tools-language/semantic/model.md index f4a60f47..13b01e92 100644 --- a/docs/verification/sysml2-tools-language/semantic/model.md +++ b/docs/verification/sysml2-tools-language/semantic/model.md @@ -3,9 +3,10 @@ #### Verification Approach Internal semantic components (`AstBuilder`, `SymbolTable`, `ReferenceResolver`, and -`SupertypeWalker`) are verified indirectly through `WorkspaceLoaderTests`. There are no direct -unit tests for these internal classes because they have no public surface. Their behavior is -observable exclusively through the public `WorkspaceLoader.LoadAsync` API. +`SupertypeWalker`) are verified indirectly through `WorkspaceLoaderTests` and the focused +`AstBuilderMetadataTests`. There are no direct unit tests for these internal classes because they +have no public surface. Their behavior is observable exclusively through the public +`WorkspaceLoader.LoadAsync` API. #### Test Environment @@ -19,10 +20,14 @@ external services, network access, or additional configuration are required beyo - All `WorkspaceLoaderTests` pass with zero failures across all three target frameworks. - `AstBuilder` correctly produces qualified names for nested packages and definitions as confirmed by tests that check `Declarations` contents. +- `AstBuilder` captures applied metadata annotations as `SysmlMetadataNode` children, including + their literal attribute values, and preserves raw bracket-filter text from + `expose ::**[]` members. - `SymbolTable` registers all named nodes from the provided AST roots; duplicate names are silently ignored without error. -- `ReferenceResolver` emits exactly one Warning per unresolved supertype name per file; it - completes without infinite loops when circular imports are present. +- `ReferenceResolver` emits exactly one Warning per unresolved supertype or metadata-type name per + file, resolves metadata annotation type references into `MetadataType` edges, and completes + without infinite loops when circular imports are present. - `SupertypeWalker` emits Warning diagnostics for cyclic specialization chains and terminates in finite time for any reachable graph. @@ -35,9 +40,14 @@ Traceability to `WorkspaceLoaderTests` test methods: | `AstBuilder` — name extraction | `WorkspaceLoader_LoadAsync_SinglePackage_RegistersDeclaration` | | `AstBuilder` — qualified names | `WorkspaceLoader_LoadAsync_NestedPackages_RegistersQualifiedNames` | | `AstBuilder` — supertype extraction | `WorkspaceLoader_LoadAsync_SpecializesChain_Registered` | +| `AstBuilder` — metadata annotation capture | `AstBuilder_BareMetadataAnnotation_CapturesMetadataNode` | +| `AstBuilder` — metadata attribute capture | `AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue` | +| `AstBuilder` — bracket-filter text capture | `AstBuilder_ExposeBracketFilter_CapturesRawText` | | `SymbolTable` — registration | `WorkspaceLoader_LoadAsync_SinglePackage_RegistersDeclaration` | | `SymbolTable` — lookup | `WorkspaceLoader_LoadAsync_SpecializesChain_Registered` | | `ReferenceResolver` — unresolved ref | `WorkspaceLoader_LoadAsync_UnresolvedReference_ProducesWarning` | +| `ReferenceResolver` — metadata type resolution | `AstBuilder_MetadataAnnotation_ResolvesTypeReference` | +| `ReferenceResolver` — metadata warning | `AstBuilder_MetadataAnnotation_UnresolvedType_ProducesWarning` | | `ReferenceResolver` — circular import | `WorkspaceLoader_LoadAsync_CircularImport_ProducesWarningNoInfiniteLoop` | | `SupertypeWalker` — chain walking | `WorkspaceLoader_LoadAsync_SpecializesChain_Registered` | | `SupertypeWalker` — cyclic detection | `WorkspaceLoader_LoadAsync_CyclicSpecialization_ProducesWarning` | diff --git a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md index d610c580..d14fe916 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -3,10 +3,10 @@ ##### Verification Approach `AstBuilder` is an internal class with no public surface and is verified indirectly through -`WorkspaceLoaderTests`. Tests call `WorkspaceLoader.LoadAsync` with controlled `.sysml` source -files and assert that the returned `SysmlLoadResult.Workspace.Declarations` contains the -expected qualified names, confirming that `AstBuilder` correctly extracted names, built -qualified names from the namespace stack, and extracted supertype names from the CST. +`WorkspaceLoaderTests` plus the focused `AstBuilderMetadataTests`. Tests call +`WorkspaceLoader.LoadAsync` with controlled `.sysml` source files and assert that the returned +`SysmlLoadResult.Workspace.Declarations` and nested node data contain the expected names, +metadata annotations, and raw filter text. ##### Test Environment @@ -25,13 +25,15 @@ external services or additional configuration are required beyond a standard .NE - A usage/feature's own usage-level `subsets`/`:>` clause (distinct from a definition's `specializes`/`:>` supertype clause) directly populates that feature node's `SupertypeNames` with the expected target name. +- A metadata annotation in an element body is captured as a `SysmlMetadataNode` child with its + raw type reference and any supported literal attribute values. - `VisitViewDefinition` captures `render ;` and `filter [];` members' raw text on the corresponding `SysmlViewNode`, and leaves both null for a view with an empty body. - `VisitViewUsage` (a named `view` usage, not a `view def` definition) captures the same render/filter members plus `expose ;` members, producing a `SysmlViewNode` with - populated `ExposedNames`. This also makes every named `view` usage its own renderable - declaration, an intentional capability addition beyond `expose` capture alone (see the - ast-builder design doc). + populated `ExposedNames` and `ExposeBracketFilterTexts`. This also makes every named `view` + usage its own renderable declaration, an intentional capability addition beyond `expose` + capture alone (see the ast-builder design doc). - `BuildUsageNode` captures a feature's redefinition reference on `RedefinedFeatureName` for both the `redefines` keyword form and the `:>>` operator form, for both a bare simple name and a qualified `Owner::feature` form (captured verbatim, unresolved), and leaves it null for a @@ -46,9 +48,12 @@ external services or additional configuration are required beyond a standard .NE | Definition registration | `WorkspaceLoader_LoadAsync_PartDef_RegistersDefinition` | | Supertype extraction | `WorkspaceLoader_LoadAsync_SpecializesChain_Registered` | | Usage-level `subsets`/`:>` capture | `WorkspaceLoader_LoadAsync_UsageLevelSubsetting_PopulatesSupertypeNames` | +| Metadata annotation capture | `AstBuilder_BareMetadataAnnotation_CapturesMetadataNode` | +| Metadata literal attribute capture | `AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue` | | `VisitViewDefinition` render | `WorkspaceLoader_LoadAsync_ViewRenderTarget_CapturedRawNeverResolvedNoDiagnostic` | | `VisitViewDefinition` filter capture | `WorkspaceLoader_LoadAsync_ViewFilterExpression_CapturesTextVerbatimNoEdge` | | `VisitViewUsage` expose capture | `WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge` | +| `VisitViewUsage` bracket-filter capture | `AstBuilder_ExposeBracketFilter_CapturesRawText` | | `VisitViewUsage` renderable declaration | `RenderSubsystem_OmgSafetyFeatureViewsCorpus_RendersAllNamedViewUsages` | | Empty view body regression guard | `WorkspaceLoader_LoadAsync_ViewEmptyBody_AllNewFieldsNullOrEmpty` | | Redefinition, `redefines` keyword | `WorkspaceLoader_LoadAsync_RedefinesKeyword_CapturesRedefinedFeatureName` | diff --git a/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md b/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md index ff8ab168..765776f8 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md @@ -25,6 +25,9 @@ external services or additional configuration are required beyond a standard .NE supertype. - `SysmlImportNode.ImportedNamespace` is extracted and used by `ReferenceResolver` to build the import graph. +- `SysmlMetadataNode` is captured as a child of the annotated element, carries its raw metadata + type reference plus any literal attribute values, and resolves that type reference into a + `MetadataType` edge when possible. - `SysmlNode.ResolvedEdges` is populated by `ReferenceResolver` with the resolved outgoing edges for a node that has at least one resolved supertype, typing, or import reference. - `SysmlNode.Annotations` is populated by `AstBuilder` with captured `comment`/`doc` text for @@ -35,6 +38,8 @@ external services or additional configuration are required beyond a standard .NE evaluated), and are `null`/empty for a view with no such members. `RenderTargetName` is captured but never resolved into an edge or diagnostic (it names a rendering style/format, not content); `ExposedNames` is the only field independently resolved by `ReferenceResolver`. +- `SysmlViewNode.ExposeBracketFilterTexts` is populated verbatim from bracketed + `expose ::**[]` members and remains capture-only Phase 1 data. - `SysmlFeatureNode.RedefinedFeatureName` is populated verbatim from a feature's `redefines`/`:>>` clause (bare-name and qualified `Owner::feature` forms, both keyword and operator syntax), and is `null` for a feature with no redefinition. It is resolved by @@ -48,11 +53,14 @@ external services or additional configuration are required beyond a standard .NE | `SysmlDefinitionNode` construction | `WorkspaceLoader_LoadAsync_PartDef_RegistersDefinition` | | `SupertypeNames` population | `WorkspaceLoader_LoadAsync_SpecializesChain_Registered` | | `SupertypeNames` usage-level population | `WorkspaceLoader_LoadAsync_UsageLevelSubsetting_PopulatesSupertypeNames` | +| `SysmlMetadataNode` capture | `AstBuilder_BareMetadataAnnotation_CapturesMetadataNode` | +| `SysmlMetadataNode` type resolution | `AstBuilder_MetadataAnnotation_ResolvesTypeReference` | | `ResolvedEdges` populated | `WorkspaceLoader_LoadAsync_ResolvedSupertype_RecordsSupertypeEdge` | | `Annotations` populated | `WorkspaceLoader_LoadAsync_CommentAndDocumentation_CapturesBothInSourceOrder` | | `RenderTargetName` unresolved | `WorkspaceLoader_LoadAsync_ViewRenderTarget_CapturedRawNeverResolvedNoDiagnostic` | | `FilterExpressionText` verbatim | `WorkspaceLoader_LoadAsync_ViewFilterExpression_CapturesTextVerbatimNoEdge` | | `SysmlViewNode.ExposedNames` from a `view` usage | `WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge` | +| `SysmlViewNode.ExposeBracketFilterTexts` verbatim | `AstBuilder_ExposeBracketFilter_CapturesRawText` | | Empty view body leaves all fields null/empty | `WorkspaceLoader_LoadAsync_ViewEmptyBody_AllNewFieldsNullOrEmpty` | | `RedefinedFeatureName` — `redefines` | `WorkspaceLoader_LoadAsync_RedefinesKeyword_CapturesRedefinedFeatureName` | | `RedefinedFeatureName` — `:>>` operator | `WorkspaceLoader_LoadAsync_ColonGtGtOperator_CapturesRedefinedFeatureName` | diff --git a/requirements.yaml b/requirements.yaml index 75ab9d1f..911e190f 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -11,6 +11,7 @@ includes: - docs/reqstream/sysml2-tools-language/semantic/ast-deserializer.yaml - docs/reqstream/sysml2-tools-language/semantic/model.yaml - docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml + - docs/reqstream/sysml2-tools-language/semantic/model/sysml-metadata-node.yaml - docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml - docs/reqstream/sysml2-tools-language/semantic/model/symbol-table.yaml - docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml @@ -18,6 +19,8 @@ includes: - docs/reqstream/sysml2-tools-stdlib.yaml - docs/reqstream/sysml2-tools-stdlib/stdlib-provider.yaml - docs/reqstream/sysml2-tools-core.yaml + - docs/reqstream/sysml2-tools-core/filtering.yaml + - docs/reqstream/sysml2-tools-core/filtering/filter-expression-evaluator.yaml - docs/reqstream/sysml2-tools-core/layout.yaml - docs/reqstream/sysml2-tools-core/layout/internal.yaml - docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs index 8a9befa6..0dbcfd49 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpression.cs @@ -2,6 +2,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // +// cspell:ignore parenthesization istype hastype + namespace DemaConsulting.SysML2Tools.Filtering; /// diff --git a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs index 317824f3..b4eb566e 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Filtering/FilterExpressionParser.cs @@ -2,6 +2,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // +// cspell:ignore parenthesization istype hastype ISTYPE HASTYPE LPAREN RPAREN + using Antlr4.Runtime; using DemaConsulting.SysML2Tools.Parser; using DemaConsulting.SysML2Tools.Parser.Antlr; diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index fea09ba9..1cd414d8 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -4,6 +4,8 @@ using System.Globalization; using DemaConsulting.SysML2Tools.Parser.Antlr; +// cspell:ignore unlexable + namespace DemaConsulting.SysML2Tools.Semantic.Model; /// @@ -1059,8 +1061,9 @@ private static (string? RenderTargetName, string? FilterExpressionText) ExtractV /// token's text with no separators. Required whenever the captured text will later be /// re-lexed on its own (e.g. FilterExpressionParser.Parse) — without the original /// inter-token spacing, adjacent keyword/identifier tokens can merge into a single token - /// (e.g. "@Safety and (as Safety)" would otherwise round-trip as - /// "@Safetyand(asSafety)", losing the and/as keyword boundaries). + /// (e.g. "@Safety and (as Safety)" would otherwise round-trip as the unlexable + /// "@Safety" + "and" + "(as" + "Safety)" run together with no separators, losing the + /// and/as keyword boundaries). /// private static string GetOriginalText(Antlr4.Runtime.ParserRuleContext context) => context.Start.InputStream.GetText( diff --git a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs index 69f33b02..e8ced73e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Filtering/FilterExpressionParserTests.cs @@ -1,6 +1,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // Licensed under the MIT License. +// cspell:ignore Parenthesization istype Istype hastype Hastype Reparses + using DemaConsulting.SysML2Tools.Filtering; namespace DemaConsulting.SysML2Tools.Tests.Filtering; diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs index ecae1979..2dbbe513 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs @@ -2,6 +2,8 @@ // Copyright (c) DemaConsulting. All rights reserved. // +// cspell:ignore istype + using DemaConsulting.SysML2Tools.Layout.Internal; namespace DemaConsulting.SysML2Tools.Tests.Layout; From b3989861a491f18e5827e70943488165b5b3f795 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 18:05:39 -0400 Subject: [PATCH 5/6] docs: update ROADMAP.md and user guide for Phase 1 filter-expression evaluation - ROADMAP.md: mark Phase 1 done, explicitly list Phase 2 deferrals (bracket expose-filter evaluation, istype/hastype/all/arithmetic/conditional/ feature-chain navigation, usage-level metadata filtering). - docs/user_guide/introduction.md: update 'View Body Statements' and 'Expose vs. Render: Worked Examples' to describe standalone filter evaluation vs. the still-capture-only bracket form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ROADMAP.md | 66 +++++++++++++++++++++------------ docs/user_guide/introduction.md | 22 ++++++++--- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c2bc8885..7c967bd7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,29 +71,49 @@ the inner `cpu` box, not the `board` container's boundary. ### View `filter [];` expression evaluation -`GeneralViewLayoutStrategy` now scopes a rendered diagram to a view's `expose <...>;` subject -subtree, but a view's `filter [];` body statement is only parsed into -`SysmlViewNode.FilterExpressionText` (raw source text) — it is never evaluated, and a -layout warning ("parsed but not yet evaluated") is emitted in its place. SysML v2 view filtering -is part of the standard's view/viewpoint mechanism for selectively including/excluding elements -from a rendered diagram by predicate, beyond simple subject-subtree containment scoping (for -example, "only elements satisfying a given requirement" or "only elements with a given -stereotype"); without evaluation, a modeler's `filter` statement is silently ineffective beyond -the warning. - -- Design and implement an expression evaluator for the bracketed filter expression grammar - (boolean/membership predicates over the resolved scope's elements), reusing/aligning with - existing expression-parsing infrastructure where practical. -- Apply the evaluated predicate as an additional filter over the resolved (expose) scope in - `GeneralViewLayoutStrategy`, removing the "not yet evaluated" warning once a filter - expression is present and successfully evaluated. -- Surface a diagnostic for filter expressions that fail to parse or evaluate, mirroring the - unresolved-reference diagnostic pattern already used for `expose`. - -**Scope:** `AstBuilder`/`SysmlViewNode` (expression AST capture, if warranted, beyond raw text); -new expression-evaluation component; `GeneralViewLayoutStrategy` filter application. -**Visual gate:** a view with a `filter [];` statement renders only the elements -satisfying the predicate, with no "not yet evaluated" warning. +**Phase 1 — done.** `GeneralViewLayoutStrategy` scopes a rendered diagram to a view's +`expose <...>;` subject subtree, and now also evaluates a standalone view `filter ;` body +statement (via the new `DemaConsulting.SysML2Tools.Core.Filtering` subsystem — +`FilterExpression`/`FilterExpressionParser`/`FilterExpressionEvaluator`) for a defined Phase 1 +construct subset, narrowing the rendered scope by the resulting predicate: + +- Metadata classification-test atoms (`@Type`, `@Pkg::Type`), matched against a new + `SysmlMetadataNode` semantic-model type capturing each definition's applied metadata + annotations (`{@Type{attr = value;}}`/`@Type;`/`@Type{}`), resolved via `ReferenceResolver`. +- Boolean connectives: `and`, `or`, `not`, `xor`, `&`, `|`, and parenthesization. +- `(as Type).attribute` reads, bare or compared with `==`/`!=` against a scalar (boolean, number, + or string) literal. + +Any construct outside this subset — `istype`/`hastype`/`all`, arithmetic, conditional +expressions, general feature-chain navigation, or a syntax error — produces an explicit +"unsupported filter construct" (or syntax-error) diagnostic and falls back to rendering the +resolved (`expose`) scope unfiltered, exactly as Phase 0 did for every filter expression. + +**Phase 2 — deferred:** + +- The bracketed `expose ::**[]` filter form: Phase 1 only captures its raw + expression text (mirroring the pre-Phase-1 standalone-`filter` behavior) and emits an + "unevaluated" warning; it is never evaluated. Phase 2 should extend the Phase 1 evaluator (or a + successor) to cover this form too. +- The Phase 1-excluded construct list above: `istype`/`hastype`/`all`, arithmetic operators, + conditional (`if`/`else`) expressions, and general feature-chain navigation (attribute/feature + reads not anchored by an `(as Type)` cast). Each currently produces a clear, non-crashing + "unsupported filter construct" diagnostic rather than silently doing nothing — full evaluation + of these constructs is future work. +- Metadata annotations on **usages** (as opposed to definitions) are captured in the semantic + model (`SysmlMetadataNode` is attached wherever `metadataFeature` appears), but + `GeneralViewLayoutStrategy`'s Phase 1 filter narrowing only evaluates classification + tests/attribute reads against rendered `SysmlDefinitionNode` candidates (matching + `CollectDefinitions`'s existing scope) — extending filter evaluation to usage-level candidates + is future work if a future view kind renders usages directly. + +**Scope:** `SysmlNode.cs`/`AstBuilder.cs`/`ReferenceResolver.cs`/`SysmlEdge.cs` (metadata +capture); `DemaConsulting.SysML2Tools.Core.Filtering` (new subsystem); `GeneralViewLayoutStrategy`/ +`LayoutWarnings` (filter application, dual unevaluated-bracket-filter warning). +**Visual gate:** a view with a standalone `filter @Type;`-style Phase 1 statement renders only +the elements satisfying the predicate, with no "not yet evaluated" warning for that statement; +an unsupported construct or a bracket-form filter still falls back to the resolved scope with an +explicit diagnostic. --- diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 066dbdfa..8b68bb54 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1,5 +1,7 @@ # Introduction + + SysML2Tools is a free, open-source .NET CLI tool and library that parses SysML v2 textual model files and renders them as professional nested block diagrams. It is designed for .NET teams in regulated industries who author SysML v2 models as part of a Model-Based Systems @@ -136,10 +138,20 @@ entire workspace: an unrecognized name) — and a view declaring no `render` member at all — has **no effect** on which strategy renders the view; see `ROADMAP.md` for further rendering-style selectors that may be added in future. -- `filter [];` — the bracketed filter expression is parsed and captured, but **not yet - evaluated**: the resolved (`expose`) scope is rendered unfiltered, and a diagnostic reports - that the filter expression was parsed but not yet evaluated. Full filter-expression - evaluation is planned future work — see `ROADMAP.md`. +- `filter ;` — a standalone view-body filter statement is now **evaluated** for a + supported subset of SysML v2 filter-expression syntax (Phase 1): metadata classification + tests (`@Type`, `@Pkg::Type`), boolean connectives (`and`, `or`, `not`, `xor`, `&`, `|`), + parenthesization, and `(as Type).attribute` reads (bare, or compared with `==`/`!=` against a + scalar literal). When the expression parses and evaluates successfully, the rendered scope is + narrowed to the definitions the predicate matches. Any construct outside this subset + (`istype`/`hastype`/`all`, arithmetic, conditional expressions, general feature-chain + navigation, etc.) — or any syntax error — produces an explicit "unsupported filter construct" + (or syntax-error) diagnostic and falls back to rendering the resolved (`expose`) scope + unfiltered, exactly as before. The bracketed `expose ::**[]` filter form remains + **parsed and captured only, not yet evaluated**, in Phase 1: it always renders its resolved + scope unfiltered, with a diagnostic reporting that its filter expression was captured but not + evaluated. Full bracket-form evaluation, and evaluation of the remaining Phase 1-excluded + constructs, are planned future work — see `ROADMAP.md`. - A view with **no** `expose` statement (including the `--auto`-synthesized view) renders the full workspace, exactly as before this scoping behavior was introduced. @@ -184,7 +196,7 @@ of confusion, so it is worth stating plainly: | --- | --- | | `expose ;` | The **only** mechanism scoping which model content appears in the diagram (see above). | | `render ;` | Selects a rendering style — see "View Body Statements" above. Never scopes content. | -| `filter [];` | Captured as raw text only; not yet evaluated (see ROADMAP.md's filter-evaluation entry). | +| `filter ;` | Narrows scope by Phase 1 metadata filtering; unsupported/bracket forms fall back unfiltered. | ### Example A: exposing a definition to scope down to a subsystem From db8be8d61ad80f94568256ef2119430cce40f1e9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 18:14:41 -0400 Subject: [PATCH 6/6] test: import ScalarValues::Boolean in safety-metadata-filter fixture to avoid spurious unresolved-reference warning Verified end-to-end via the CLI: 'render' on this fixture now produces no diagnostics, and SafetyPartsView/MandatorySafetyPartsView SVG output correctly reflects the Phase 1 filter-narrowed scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/SysMLModels/Custom/safety-metadata-filter.sysml | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SysMLModels/Custom/safety-metadata-filter.sysml b/test/SysMLModels/Custom/safety-metadata-filter.sysml index 2d5eb64d..70449203 100644 --- a/test/SysMLModels/Custom/safety-metadata-filter.sysml +++ b/test/SysMLModels/Custom/safety-metadata-filter.sysml @@ -1,4 +1,5 @@ package RobotArm { + private import ScalarValues::Boolean; metadata def Safety { attribute isMandatory : Boolean;