diff --git a/README.md b/README.md index 8ce5c06..a9776ca 100644 --- a/README.md +++ b/README.md @@ -289,8 +289,11 @@ sysml2tools help [lint|render|query []|export] | `--view ` names a view that does not exist | Error: lists available view names and exits non-zero | For every layout strategy (not just the General View), a view's body `expose <...>;` statements -now scope the rendered diagram to the union of the exposed names' containment subtrees, instead -of always rendering the full workspace; a view with no `expose` statement continues to render +now scope the rendered diagram to the union of the exposed entries' resolved scopes — an exact +match, direct children only, or the entire containment subtree, depending on each entry's SysML +v2 `expose` grammar form and recursion setting (`expose X;`/`expose X::**;`/`expose X::*;`/ +`expose X::*::**;`) — instead of always rendering the full workspace; a view with no `expose` +statement continues to render the full workspace. `render asTreeDiagram;` and `render asInterconnectionDiagram;` now select the Browser View and Interconnection View layout strategies respectively, taking precedence over the name/supertype heuristic; other `render` targets (`asElementTable`, `asTextualNotation`, an diff --git a/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md b/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md index 78bdc7a..c9343ec 100644 --- a/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md +++ b/docs/design/sysml2-tools-core/filtering/filter-expression-evaluator.md @@ -234,7 +234,7 @@ lexer/recursive-descent-parser internals could violate that contract despite `Pa `BracketFilterExpressionText` with `FilterExpressionParser.Parse`, evaluates successful ASTs with `FilterExpressionEvaluator.Evaluate` against that entry's own containment-subtree candidate set, adds matches to the resolved `ExposedScope.ExplicitMembers`, and falls back to - whole-subtree inclusion (`PrefixSubjects`) plus a recorded `BracketFilterFailure` on parse or + whole-subtree inclusion (`Subjects`) plus a recorded `BracketFilterFailure` on parse or evaluation failure. No change was required in this unit to support this second caller. - `FilterExpressionParserTests` and `FilterExpressionEvaluatorTests` exercise the parser, pretty-printer, and evaluator directly. diff --git a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md index 50990fe..c150715 100644 --- a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -12,17 +12,21 @@ each maintaining its own copy, so every view kind honors `expose` scoping identi ##### Data Model `ExposeScopeResolver` is a static class with no instance state; input arrives through method -parameters, and (Phase 2a) resolution output is returned as two internal record types: - -- `ExposedScope(IReadOnlyList PrefixSubjects, IReadOnlyList ExplicitMembers)` — - the resolved scope. `PrefixSubjects` are exposed subject qualified names whose entire - containment subtree is in scope (the pre-Phase-2a whole-subtree behavior, used for `expose` - entries with no bracket filter and as the fallback for a bracket-filtered entry that failed to - parse or evaluate). `ExplicitMembers` are individual qualified names — of a `SysmlDefinitionNode` - **or a named `SysmlFeatureNode`** (Phase 2d — widened from definitions-only, see below) — - matched by a successfully-evaluated bracket filter — exact matches only, not their own nested - members unless those also match. A settable `Failures` init-property (default empty) carries any - bracket filters that failed to parse or evaluate. +parameters, and resolution output is returned as three internal record types: + +- `ExposedScope(IReadOnlyList Subjects, IReadOnlyList ExplicitMembers)` — + the resolved scope. `Subjects` are exposed subject qualified names, each paired with the + `ExposeRecursionKind` that governs how it matches a candidate qualified name (see + `IsInSubjectScope` below) — derived from each `expose` entry's own SysML v2 grammar form + (MembershipExpose/NamespaceExpose) and recursion setting (a trailing `::**`), and forced to + whichever *Recursive variant matches the entry's form for the fallback path (a bracket-filtered + entry that failed to parse or evaluate). `ExplicitMembers` are individual qualified names — of a + `SysmlDefinitionNode` **or a named `SysmlFeatureNode`** (Phase 2d — widened from + definitions-only, see below) — matched by a successfully-evaluated bracket filter — exact + matches only, not their own nested members unless those also match. A settable `Failures` + init-property (default empty) carries any bracket filters that failed to parse or evaluate. +- `ExposeSubject(string QualifiedName, ExposeRecursionKind Recursion)` — one resolved `expose` + subject qualified name paired with the recursion kind governing how it matches candidates. - `BracketFilterFailure(string ExpressionText, string? Reason)` — one failed bracket-filter expression's raw source text plus a short human-readable reason, feeding `LayoutWarnings.ForUnevaluatedExposeBracketFilter`. @@ -31,19 +35,19 @@ parameters, and (Phase 2a) resolution output is returned as two internal record ###### `ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode)` -Resolves the qualified-name containment-subtree scope a view's `expose` statements restrict the -diagram to — the only content-scoping mechanism a view has (`render ;` names a -rendering style/format, e.g. `asTreeDiagram`/`asElementTable`, per the SysML v2 grammar, never -content, so `RenderTargetName` never affects this decision). Returns `null` — meaning "render -everything", the pre-scoping behavior — whenever the view has no resolved `Expose`-kind -`ResolvedEdges` entries: covering a `null` `viewNode` (the `--auto` synthesized view, which never -carries expose/render/filter data), a view with no `expose` statement, and a view whose every -`expose` entry failed to resolve, uniformly. Otherwise, for each resolved `Expose` edge, re-pairs -the edge's target with the `ExposeMember` it originated from (see Design below) and: - -- when that entry carries no bracket-filter expression, adds the target (and, for a usage target, - its resolved type — see the usage-to-type fallback below) to `PrefixSubjects`, unchanged from - Phase 1; +Resolves the qualified-name scope a view's `expose` statements restrict the diagram to — the only +content-scoping mechanism a view has (`render ;` names a rendering style/format, e.g. +`asTreeDiagram`/`asElementTable`, per the SysML v2 grammar, never content, so `RenderTargetName` +never affects this decision). Returns `null` — meaning "render everything", the pre-scoping +behavior — whenever the view has no resolved `Expose`-kind `ResolvedEdges` entries: covering a +`null` `viewNode` (the `--auto` synthesized view, which never carries expose/render/filter data), a +view with no `expose` statement, and a view whose every `expose` entry failed to resolve, +uniformly. Otherwise, for each resolved `Expose` edge, re-pairs the edge's target with the +`ExposeMember` it originated from (see Design below) and: + +- when that entry carries no bracket-filter expression, calls `AddSubject` with the entry's own + `ExposeRecursionKind` — adding the target (and, for a usage target, its resolved type — see the + usage-to-type fallback below) as an `ExposeSubject` classified by that kind; - when that entry's bracket-filter expression parses and evaluates successfully (Phase 2a), computes the candidate set as every `workspace.Declarations` key that is the target itself or lies in its containment subtree (`qn == target || qn.StartsWith(target + "::")`) *and* is a @@ -52,27 +56,39 @@ the edge's target with the `ExposeMember` it originated from (see Design below) usage-level candidate too) — mirroring `GeneralViewLayoutStrategy.CollectDefinitions`'s candidate-set restriction — evaluates with `FilterExpressionEvaluator.Evaluate` against that candidate set unchanged, and adds the matched subset to `ExplicitMembers`; -- when that entry's bracket-filter expression fails to parse or evaluate, falls back to - whole-subtree inclusion (`PrefixSubjects`, same as the unfiltered case) and records a +- when that entry's bracket-filter expression fails to parse or evaluate, falls back to calling + `AddSubject` with whichever *Recursive variant matches the entry's form (never the narrower + Exact/DirectChildren kind, regardless of the entry's own classification) and records a `BracketFilterFailure` with the raw expression text and a short reason, so the caller can degrade gracefully instead of silently losing the exposed path's content. When an exposed target resolves to a `SysmlFeatureNode` (a usage, e.g. `part myVehicle : Vehicle;`) rather than a `SysmlDefinitionNode`, the usage's own containment subtree is typically empty — the real content lives under its type's subtree. To avoid silently -scoping to nothing, whole-subtree inclusion also resolves the usage's own `Typing`-kind -`ResolvedEdges` entry (if any) and adds that type's qualified name to `PrefixSubjects` too, so both -the usage and its type's subtree are included. This expansion only applies to whole-subtree -inclusion — a successfully-evaluated bracket filter's `ExplicitMembers` already name the exact -matched definitions/usages directly. +scoping to nothing, `AddSubject` also resolves the usage's own `Typing`-kind `ResolvedEdges` entry +(if any) and adds that type's qualified name as an `ExposeSubject`, **using the same recursion +kind** as the usage's own subject — so a non-recursive (`MembershipExact`) usage expose adds the +type exact-match only, not its subtree, while a recursive usage expose adds the type with +whole-subtree matching, same as before this fix. This expansion only applies to `Subjects` — a +successfully-evaluated bracket filter's `ExplicitMembers` already name the exact matched +definitions/usages directly. ###### `IsInSubjectScope(string qualifiedName, ExposedScope scope)` -Returns `true` when `qualifiedName` equals one of `scope.PrefixSubjects` or lies within one of -their containment subtrees (a `"{subject}::"` prefix match, the same qualified-name-prefix idiom -`StdlibFilter.IsStdlibElement` already uses for stdlib-prefix matching), or is an exact match of -one of `scope.ExplicitMembers` (a bracket-filter-matched definition or usage). Used by every strategy to -decide whether a candidate element belongs in a scoped diagram. +Returns `true` when `qualifiedName` matches one of `scope.Subjects` per that subject's own +`ExposeRecursionKind`: + +- `MembershipRecursive`/`NamespaceRecursive` — `qualifiedName` equals the subject or lies within + its containment subtree (a `"{subject}::"` prefix match, the same qualified-name-prefix idiom + `StdlibFilter.IsStdlibElement` already uses for stdlib-prefix matching) — the unchanged + whole-subtree behavior from before this fix. +- `MembershipExact` — `qualifiedName` equals the subject exactly, and nothing else. +- `NamespaceDirectChildren` — `qualifiedName` is a direct (one-level) child of the subject only, + via a private `IsDirectChildOf` helper (`StartsWith(subject + "::")` **and** the remainder + contains no further `"::"`) — not the subject itself, not a deeper descendant. + +or is an exact match of one of `scope.ExplicitMembers` (a bracket-filter-matched definition or +usage). Used by every strategy to decide whether a candidate element belongs in a scoped diagram. ###### `IsRootRelevantToScope(string candidateQualifiedName, ExposedScope scope)` @@ -80,11 +96,15 @@ Returns `true` when `candidateQualifiedName` (a candidate single-root diagram ro definition `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, `ActionFlowViewLayoutStrategy`, or `SequenceViewLayoutStrategy` would otherwise pick by its own heuristic) is related to the resolved `expose` scope in `scope`, in either containment -direction — checked against both `scope.PrefixSubjects` and `scope.ExplicitMembers`: the candidate -itself is an exposed subject or matched member, the candidate lies within an exposed subject's -containment subtree, or an exposed subject/matched member lies within the candidate's own -containment subtree (the common "expose an inner state/action/part/lifeline of the root" case). -This method identifies the *set* of scope-relevant candidates only — because SysML v2 definitions +direction — checked against both `scope.Subjects.Select(s => s.QualifiedName)` and +`scope.ExplicitMembers`: the candidate itself is an exposed subject or matched member, the +candidate lies within an exposed subject's containment subtree, or an exposed subject/matched +member lies within the candidate's own containment subtree (the common "expose an inner +state/action/part/lifeline of the root" case). This anchor-qualified-name containment check is +unaffected by each subject's `ExposeRecursionKind` — root-candidate relevance only needs to find +the right root to render, independent of whether the final in-scope set (decided separately by +`IsInSubjectScope`) is the full subtree, a single exact member, or one level of children. This +method identifies the *set* of scope-relevant candidates only — because SysML v2 definitions may nest, an ancestor and one of its nested descendant definitions can both be relevant to the same resolved scope. Disambiguating among multiple relevant candidates is delegated to `IsMoreSpecificCandidate`, not decided by this method. 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 846c450..1fb8966 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 @@ -6,14 +6,18 @@ renders every user-defined definition (part, port, interface, requirement, action, and so on) and — since Phase 2d (see `ROADMAP.md`'s "View `filter [];` expression evaluation" section) — every named usage-level declaration too, as a keyword-labeled box, groups the boxes that belong to -a package inside a folder-shaped -container, lists each definition's owned usages in compartments, and draws specialization, +a bare package (one with no admitted definition as its own owner) inside a folder-shaped +container when the view is unscoped, nests a definition that owns nested definitions recursively +inside that definition's own box rather than as a duplicate sibling container, lists each +definition's owned usages in compartments, and draws specialization, membership, attribute-typing, redefinition, subsetting, connect, allocate, dependency, and binding edges orthogonally between the boxes. The whole diagram — package -folders and definitions alike — is expressed as a single `DemaConsulting.Rendering` `LayoutGraph` +folders, definition-as-container boxes, and plain definitions alike — is expressed as a single +`DemaConsulting.Rendering` `LayoutGraph` and placed with one `HierarchicalLayoutAlgorithm.Apply` call: the root scope packs package folders -and top-level definitions by reading order (`ContainmentLayoutAlgorithm`), while each folder's own -contents are ordered by their intra-package edges with the bundled layered algorithm +and top-level definitions by reading order (`ContainmentLayoutAlgorithm`), while each container's +own contents (a folder's or a nested-owning-definition's) are ordered by their intra-container +edges with the bundled layered algorithm (`LayeredLayoutAlgorithm`). All box sizing (title bands, compartment rows) remains this strategy's responsibility, since the layout stage in `DemaConsulting.Rendering.Layout` is theme-agnostic; box title and folder-tab geometry come from `BoxMetrics` in `DemaConsulting.Rendering.Abstractions`. @@ -29,9 +33,10 @@ own `SysmlNode.Annotations` list, threaded through by `CollectDefinitions` so `A specialization/membership/attribute-typing/redefinition/subsetting/connect/allocate/dependency/ binding relationship expressed by qualified name, together with its target end marker, edge kind, and an optional midpoint label), `Location` (a located definition's -graph node and owning package, used to resolve and scope edges), and `TruncatedFolder` (a -depth-truncated package folder's leaf graph node and hidden-definition count, used to stamp its -ellipsis label onto the placed box after layout). `DroppedRelationshipEdge` (a private record) +graph node and owning package, used to resolve and scope edges), and `TruncatedContainer` (a +depth-truncated package folder's or nested-definition-containing definition's leaf placeholder graph +node, hidden-item count, and vertical title-band offset, used to stamp its ellipsis label onto the +placed box after layout — see `DecorateTruncated` below). `DroppedRelationshipEdge` (a private record) carries the short kind label, raw source/target reference, and human-readable reason for one `Connect`/`Allocate`/`Dependency`/`Binding` edge `BuildModelEdges` could not map to two distinct rendered boxes, feeding `LayoutWarnings.ForDroppedRelationshipEdges`. `FeatureMembership` (a private record) carries @@ -75,19 +80,25 @@ calls `RemoveRedundantNestedUsages(defs)` (see below), which drops any usage-lev nearest still-rendered ancestor is also present in the final `defs` set — this must run after both narrowing steps, not before or inside `CollectDefinitions`, so a usage whose parent was excluded by a filter (rather than genuinely absent from scope) survives as its own standalone box. The method returns -the same minimal empty canvas if the dedup empties `defs`. The remaining pipeline is unchanged: -definitions are grouped by package -with `GroupByPackage`, the specialization/membership/attribute-typing/redefinition/subsetting/ +the same minimal empty canvas if the dedup empties `defs`. The remaining pipeline partitions the +surviving `defs` into `childrenByParent` (items whose immediate parent — the qualified-name prefix +before the last `"::"` — is itself an admitted definition, keyed by that parent's own qualified +name) and the rest (`packageOrRootItems`), grouping only the latter by package +with `GroupByPackage` — this partitioning is what prevents a definition that owns nested +definitions from being handed to `GroupByPackage` a second time under its own qualified name, which +is what used to render it twice (see `GroupByPackage` and `BuildGraph`/`PlaceDef` below). The +specialization/membership/attribute-typing/redefinition/subsetting/ connect/allocate/dependency/binding relationships are resolved into qualified-name edges (plus any dropped-edge diagnostics) with `BuildModelEdges`, the single input `LayoutGraph` is built -with `BuildGraph`, and the whole graph is placed with one +with `BuildGraph` (passed `childrenByParent` and whether the view has a resolved scope), 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, the returned tree's `Warnings` concatenates +is never misled into skipping the hierarchical engine. When any package folder or +nested-definition-containing definition was depth-truncated, `DecorateTruncated` stamps each +truncated container's "+N more…" ellipsis label onto its placed box. Finally, the returned tree's `Warnings` concatenates `LayoutWarnings.ForUnevaluatedFilter` (only when standalone filter parsing/evaluation failed), `LayoutWarnings.ForUnevaluatedExposeBracketFilter(context.ViewName, scope?.Failures ?? [])` (Phase 2a: one warning per bracket-filter expression that failed to parse or evaluate — a @@ -185,7 +196,12 @@ shown anywhere else and must render standalone (and is therefore never silently ###### `GroupByPackage(defs)` Groups definitions by the qualified-name prefix before the last `::`, preserving first-seen order. -Top-level definitions (no package prefix) become plain leaves directly on the root graph. +Top-level definitions (no package prefix) become plain leaves directly on the root graph. This +method is unchanged by the nested-definition-containment fix: `BuildLayout` now only ever calls it +with the subset of definitions whose immediate parent is *not* itself an admitted definition (see +`BuildLayout` above), so an admitted definition's own qualified name can never again become one of +its bucket keys — which is precisely what removes the duplicate-folder symptom, without any change +to this method's own flat, first-seen-order bucketing logic. ###### `BuildModelEdges(defs, workspace, isScoped)` @@ -300,7 +316,7 @@ longest-prefix-wins) would resolve *both* sides of the dominant real-world corpu — to the same box (`Drone`), producing a false self-loop where a real diagram must show two distinct boxes (`Controller` and `Battery`) connected. -###### `BuildGraph(groups, modelEdges, theme, depthLimit)` +###### `BuildGraph(groups, childrenByParent, modelEdges, theme, depthLimit, isScoped)` Builds the single input `LayoutGraph`, setting `CoreOptions.MergeParallelEdges` to `false` on the root graph so multiple distinct model relationship edges that happen to share the same source and @@ -309,26 +325,100 @@ another edge between the same two definitions) are never collapsed by the bundle algorithm's default parallel-edge merging — every distinct model relationship this strategy adds remains its own visible, independently-routed edge, unlike `LayeredPlacement`'s helper (used by the flat view strategies), which defaults to the algorithm's own merge-by-default behavior unless a -caller opts out (see `LayeredPlacement`'s design documentation). Each package becomes a folder -container node -(`Shape = Folder`, `Keyword = "package"`, `Label` the simple package name, `TitleHeight` set from +caller opts out (see `LayeredPlacement`'s design documentation). Every definition — whether a +top-level item, a bare package's item, or a definition nested inside another definition — is placed +through the recursive `PlaceDef` helper (below), which is what actually creates each definition's +node and recurses into its own `childrenByParent` entry, if any. + +For each `(package, items)` group: an empty `package` key means top-level (unpackaged) items, +placed via `PlaceDef` directly on the root graph. When `isScoped` is `true` (the view has a +resolved `expose` scope), a non-empty `package` key is **never** turned into a folder — its items +are placed via `PlaceDef` directly on the root graph instead, exactly like top-level items: a bare +package that is merely an ancestor of admitted content is not something the `expose` scope actually +requested, so no folder is drawn for it. When `isScoped` is `false`, a non-empty `package` key +becomes a folder container node (`Shape = Folder`, `Keyword = "package"`, `Label` the simple +package name, `TitleHeight` set from `BoxMetrics.TitleAreaHeight` so the hierarchical engine reserves the exact title band the renderer -will draw) holding its definitions as leaf nodes under `folder.Children`; the folder's own +will draw), and its items are placed via `PlaceDef` under `folder.Children`; the folder's own `CoreOptions.Algorithm` is set to `LayeredLayoutAlgorithm.AlgorithmId` per the established per-container-algorithm convention (the algorithm override lives on the container node itself, while -every other `CoreOptions` property would live on its `Children` graph). Top-level (unpackaged) -definitions become plain leaves directly on the root graph. When the depth limit forbids a folder's -nested level, its definitions are never added as individual boxes at all — the folder becomes a -single leaf node (its `Children` graph is never touched, so the hierarchical engine keeps this -caller-computed ellipsis size rather than auto-sizing it as a container) sized like the previous -ellipsis-indicator formula, and recorded as a `TruncatedFolder` for later decoration. Every located -definition's node and owning package is recorded in a `located` map so model edges can be resolved -and scoped: an edge whose endpoints share a non-empty package is added to that folder's own -`Children` scope (an intra-package edge the layered algorithm can use to order the folder's -contents); every other edge — including any crossing packages — is added at the root, referencing -the (possibly nested) endpoint nodes directly, per the graph's lowest-common-ancestor edge -convention. An edge touching a depth-truncated (unrendered) definition has no node in the `located` -map to reference and is silently dropped, exactly as before. +every other `CoreOptions` property would live on its `Children` graph). When the depth limit +forbids a folder's nested level (and the view is unscoped — depth-truncation and scope-suppression +never both apply to the same folder, since the scope-suppression branch already diverted a scoped +folder's items before the depth check runs), its definitions are never added as individual boxes at +all — the folder becomes a single leaf node (its `Children` graph is never touched, so the +hierarchical engine keeps this caller-computed ellipsis size rather than auto-sizing it as a +container) sized like the previous ellipsis-indicator formula, and recorded as a `TruncatedContainer` +for later decoration. + +Package-folder contents (and the top-level/scoped-promoted items placed directly on the root graph) +sit at depth 1 — the pre-existing convention `truncateFolderContents` (`depthLimit > 0 && depthLimit +<= 1`) already relies on. Every `PlaceDef` call `BuildGraph` makes passes this same `rootDepth == 1` +as `def`'s own starting depth, so `PlaceDef`'s own nested-definition-containment recursion (see +below) goes one depth deeper per level from this same baseline — a definition's immediate nested +child sits at depth 2, its grandchild at depth 3, and so on — regardless of whether that definition +sits directly on the root, inside a folder, or is itself already nested inside another definition. +This is what fixes the reviewer-confirmed defect where `RenderOptions.DepthLimit` only capped +package-folder-contents nesting and had no effect at all on nested-definition containment. + +Every located definition's node and owning container scope key ("package", or an admitted +definition's own qualified name — see `PlaceDef`) is recorded in a `located` map so model edges can +be resolved and scoped: an edge whose endpoints share the same non-empty scope key is added to that +container's own `Children` scope (an intra-container edge the layered algorithm can use to order +the container's contents); every other edge — including any crossing containers — is added at the +root, referencing the (possibly nested) endpoint nodes directly, per the graph's +lowest-common-ancestor edge convention. An edge touching a depth-truncated (unrendered) definition +has no node in the `located` map to reference and is silently dropped, exactly as before. + +###### `PlaceDef(def, targetScope, packageKeyForEdgeScoping, childrenByParent, located, containerScopes, ...)` + +Places a single definition's node into `targetScope` via `MakeDefNode`, records its `Location` in +`located` (keyed by the container scope it was placed in, for edge scoping), and emits its +annotation note via `AddAnnotationNote` — exactly the three steps `BuildGraph` used to perform +inline for every leaf definition, now factored into one recursive helper. `depth` is `def`'s own +nesting depth, using the same numbering `BuildGraph` already uses for package-folder contents (a +top-level or folder-scoped definition is depth 1, per `BuildGraph`'s `rootDepth` convention above); +`def`'s own immediate nested children (if any) sit one depth deeper, at `depth + 1`. + +When `def` owns nested definitions (a `childrenByParent` entry keyed by `def.QualifiedName`), the +definition's own node's `TitleHeight` is set to `def.Height` (which already includes the title band +and every compartment row for `def`'s own owned usages, from `ComputeBoxSize`) regardless of what +happens next, reserving exactly that band above where its nested content begins. Then, exactly one +of two things happens to that nested content: + +- If `depthLimit` is positive and does not permit the children's depth (`depthLimit <= depth + 1`), + the children are **not** recursed into or rendered as individual boxes at all — mirroring + `BuildGraph`'s `truncateFolderContents` package-folder-contents truncation one nesting level + deeper. A single ellipsis placeholder leaf node (sized by the same `ComputeEllipsisSize` helper + `BuildGraph`'s folder truncation uses) is added to `def`'s own `node.Children` scope instead, and + recorded in `truncated` with a `TitleOffset` of `0` (unlike a truncated package folder's + placeholder, this ellipsis node is an ordinary child placed inside `def`'s own already-title + reserved `Children` scope, not a standalone box carrying its own title/keyword band, so no further + offset is needed once its absolute position is known). No `containerScopes` entry is registered + for `def.QualifiedName` in this branch (nothing beyond the single ellipsis leaf is ever placed in + its scope, so no intra-container edge could resolve there), and none of the hidden children + receive a `located` entry, so every edge touching one is dropped exactly like a depth-truncated + package folder's contents. +- Otherwise, `node.Children` is registered in `containerScopes` under `def.QualifiedName` so + intra-container edges between `def`'s own nested children resolve to `def`'s scope rather than + the root, and each child is placed by a recursive call to `PlaceDef` with `node.Children` as the + new target scope, `def.QualifiedName` as the new edge-scoping key, and `depth + 1` as the child's + own depth — resolving arbitrarily deep nesting (a definition owning a definition owning a + definition, and so on) through repeated single-level recursion, one call per level, rather than a + multi-hop ancestor walk, with the depth-limit check re-applied at every level. + +`LayoutGraphNode.Children` and `TitleHeight` are shape-agnostic in `DemaConsulting.Rendering.Layout` +(not reserved for `BoxShape.Folder`), so a `Rectangle`-shaped definition box can safely become a +container this way with no engine-side change: the `HierarchicalLayoutAlgorithm`'s cascading sizing +pass grows any container whose placed content needs more room than its initial `Width`/`Height`, +exactly as an (initially `0, 0`-sized) package folder already relied on. + +###### `ComputeEllipsisSize(hiddenCount, theme)` + +Computes the width/height of a "+N more…" ellipsis placeholder leaf from its hidden-item count. +Factored out of the pre-existing package-folder-contents-truncation sizing formula so both +`BuildGraph`'s folder truncation and `PlaceDef`'s nested-definition-containment truncation size +their placeholder identically. ###### `MakeDefNode(scope, def)` @@ -357,18 +447,26 @@ single-pass `BuildGraph`/`CollectDefinitions` traversal that builds each definit deliberate, documented deviation from a literal "post-layout marker decoration" approach (the pattern used by, e.g., `StateTransitionViewLayoutStrategy.AddInitialMarker`, which stamps a marker onto an already-placed box after layout completes). This strategy has no equivalent -post-layout decoration phase — its entire graph, including folder containers and truncated-folder +post-layout decoration phase — its entire graph, including folder containers and truncated placeholders, is built in one `BuildGraph` pass before layout ever runs — so adding the note as an ordinary graph node/edge pair up front is the natural fit, and lets the layout engine route and position it like any other element rather than requiring bespoke placement math. -###### `DecorateTruncatedFolders(tree, graph, truncated, theme)` - -Replaces each truncated folder's placed box with one carrying its "+N more…" ellipsis label, -positioned within the box's now-known absolute placement. Because the leaf algorithm at a -compound-graph scope emits one box per node in `graph.Nodes` order, the boxes portion of the root -`LayoutTree.Nodes` aligns with `graph.Nodes` by index, so each truncated folder's placed box can be -found by index without any auxiliary identifier lookup (`LayoutBox` carries no `Id`). +###### `DecorateTruncated(tree, graph, truncated, theme)` + +Replaces each truncated container's placed box with one carrying its "+N more…" ellipsis label, +positioned within the box's now-known absolute placement, using each `TruncatedContainer`'s own +`TitleOffset` to position the label below any title/keyword band the placeholder box itself +carries. Delegates to a recursive worker overload that walks the tree scope-by-scope: at every +compound-graph scope (the root graph, or any container node's own `Children` graph), the leaf +algorithm emits one box per node in that scope's own `Nodes` order, so a scope's placed boxes align +with that same scope's graph nodes by index (`LayoutBox` carries no `Id` to match by instead). The +worker decorates any box whose matching graph node is a recorded truncation, and otherwise — when +that graph node itself owns children and the placed box has its own nested children — recurses one +level deeper into that box's own `Children` against the graph node's own `Children.Nodes`. This is +what lets a truncated nested-definition-containment placeholder (potentially several levels deep — +inside a folder, inside another definition, or both) be found and decorated exactly like a +root-level truncated package folder, not just the ones directly on the root graph. ##### Error Handling diff --git a/docs/design/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.md b/docs/design/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.md index 5e61ac0..64491fc 100644 --- a/docs/design/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.md +++ b/docs/design/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.md @@ -70,13 +70,16 @@ Performs four steps, in order, short-circuiting to a diagnostic on the first fai the rare case where it is already present (e.g., a repeat `Synthesize` call against the same workspace instance for the same target). - `RenderTargetName` set to the resolved token from step 1. - - A single-entry `ExposeMembers` list (`[new ExposeMember(targetQualifiedName, null)]`) and + - A single-entry `ExposeMembers` list (`[new ExposeMember(targetQualifiedName, null, + ExposeRecursionKind.MembershipRecursive)]`) and matching single-entry `ResolvedEdges` list (`[new SysmlEdge(viewQualifiedName, targetQualifiedName, SysmlEdgeKind.Expose)]`) — the same two properties a real, parsed - `view def V { expose Target; }` produces via `ReferenceResolver`, so + `view def V { expose Target::**; }` produces via `ReferenceResolver`, so `ExposeScopeResolver.ResolveExposedScope` (which reads only these two properties, with no - notion of provenance) scopes the rendered diagram to exactly the requested target. This is - the key difference from `DiagramRenderer.SynthesizeAutoView`, whose synthesized node carries + notion of provenance) scopes the rendered diagram to the requested target's whole + containment subtree (matching the pre-fix behavior for dynamic/ad-hoc views, which are + intended to show the full context around a requested element rather than the element + alone). This is the key difference from `DiagramRenderer.SynthesizeAutoView`, whose synthesized node carries no `ResolvedEdges` at all — that absence is what makes `ExposeScopeResolver` return a `null` scope (render everything); a dynamic view instead always resolves to a definite, non-null scope. 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 c94341c..a5fd937 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -213,29 +213,52 @@ whitespace so the Filtering subsystem can re-lex it faithfully rather than recei `ExtractExposedNames(IEnumerable bodyItems)` collects one `ExposeMember` per `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. When an `expose` member uses the dominant corpus form -`qualifiedName::**[]`, the same helper also returns the bracketed filter expression's -original source text, which `ExtractExposedNames` pairs together with that same entry's qualified -name into a single `ExposeMember(QualifiedName, BracketFilterExpressionText)` record — rather than +identical grammar shape `import` uses. It captures `isNamespaceForm = expose.namespaceExpose() is +not null` (which grammar alternative — `namespaceExpose`/`membershipExpose` — matched) *before* +calling `ExtractImportTarget`, since the call site already knows this from its own branch, and +takes the helper's 4th tuple element (`isRecursive`) to classify the entry's `ExposeRecursionKind` +via the four-way truth table: bracket-filtered entries (`hasBracketFilter`) always map to whichever +*Recursive variant matches `isNamespaceForm`; non-bracket-filtered entries map +`(isNamespaceForm, isRecursive)` to `MembershipExact`/`MembershipRecursive`/ +`NamespaceDirectChildren`/`NamespaceRecursive` directly. When an `expose` member uses the dominant +corpus form `qualifiedName::**[]`, the same helper also returns the bracketed filter +expression's original source text, which `ExtractExposedNames` pairs together with that same +entry's qualified name and recursion kind into a single +`ExposeMember(QualifiedName, BracketFilterExpressionText, RecursionKind)` record — rather than appending the qualified name and the bracket-filter text to two separate, unpaired flattened lists (the earlier Phase 1 shape) — so a view with more than one `expose` member never loses track of which bracket filter belongs to which exposed path. `ExposeScopeResolver` (Phase 2a) depends on -this pairing to evaluate each entry's bracket filter against that entry's own containment subtree. +this pairing to evaluate each entry's bracket filter against that entry's own containment subtree, +and (per this fix) reads each entry's `RecursionKind` to decide between exact/direct-children/ +whole-subtree matching. `ExtractImportTarget(NamespaceImportContext?, MembershipImportContext?)` is a shared helper -extracted from `VisitImportRule`'s previously inline logic, returning the extracted -qualified/dotted name text and whether the reference is a wildcard, for either the -namespace-import form (`qualifiedName::*`, always a wildcard), the membership-import form -(`qualifiedName`, optionally `::**`), or the bracketed-filter form nested inside a -namespace-import (`qualifiedName::**[]`) — the dominant `expose` shape in the real -OMG corpus (e.g. `expose vehicle::**[@Safety];`). The grammar nests the qualified name two levels -deeper for that third form: `namespaceImport -> filterPackage -> filterPackageImportDeclaration -> +extracted from `VisitImportRule`'s previously inline logic, returning a 4-tuple: the extracted +qualified/dotted name text, whether the reference is a wildcard (`IsWildcard`, unchanged — still +consumed verbatim by `VisitImportRule` for plain `import`, whose behavior this fix does not +alter), the bracket-filter expression text (if any), and (added by this fix) `IsRecursive` — +whether the entry is recursive, derived from a trailing `::**` per branch: for the direct +namespace-import form (`qualifiedName::*`), `namespaceImport.STAR_STAR()` (previously ignored — +the pre-fix bug hard-coded this branch's recursion bit away); for either bracketed-filter +sub-form, always `true` (bracket-filtered forms are treated as always recursive by design, since +the grammar only reaches the bracket-filter alternative via `::**[filterExpr]` — the +recursive/wildcard token always precedes the bracket); for the membership-import form +(`qualifiedName` optionally `::**`), the same `STAR_STAR()` check `IsWildcard` already computed +for that branch (already correct, just +previously unexposed as its own named field). This covers either the namespace-import form +(`qualifiedName::*`, always a wildcard), the membership-import form (`qualifiedName`, optionally +`::**`), or the bracketed-filter form nested inside a namespace-import +(`qualifiedName::**[]`) — the dominant `expose` shape in the real OMG corpus (e.g. +`expose vehicle::**[@Safety];`). The grammar nests the qualified name two levels deeper for that +third form: `namespaceImport -> filterPackage -> filterPackageImportDeclaration -> (membershipImport | namespaceImportDirect)`. `ExtractImportTarget` descends into `filterPackage().filterPackageImportDeclaration()` and extracts the qualified name from whichever of `membershipImport()`/`namespaceImportDirect()` is present there, rather than only checking the direct `qualifiedName()` child (which is null for this alternative). `VisitImportRule` and `ExtractExposedNames` both call this one helper rather than duplicating the extraction logic, per -the Copy-Paste Programming anti-pattern guidance in coding-principles.md. +the Copy-Paste Programming anti-pattern guidance in coding-principles.md; `VisitImportRule` +ignores the 4th (`IsRecursive`) element via a discard, since plain `import` has no analogous +recursion-kind concept and its behavior is unchanged by this fix. `VisitRequirementUsage` and `VisitConcernUsage` push a namespace scope (when named) and call `CollectChildren(body?.requirementBodyItem() ?? [])` in addition to their existing name/qualified- 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 a2cfcbd..ba2c909 100644 --- a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md @@ -147,24 +147,33 @@ There are no behavioral methods beyond the inherited `object` members. `SysmlImp rendered scope. Reserved for a possible future capability that selects among rendering-style strategies — see the project ROADMAP. - `ExposeMembers` — each `expose [::**[]];` member in a `view` usage's body, in - source order, paired as an `ExposeMember(string QualifiedName, string? BracketFilterExpressionText)` - record — the qualified-name reference text plus that specific entry's own bracket-filter - expression text, or null when the entry carries none. Empty when no `expose` members are - present (and always empty for a `view def` definition — `expose` is only valid grammar inside a - `view` usage's body). Extracted by `AstBuilder.ExtractExposedNames`, sharing the same - `ExtractImportTarget` helper `VisitImportRule` uses for plain `import`. `GetExposedNames()` - projects each entry's `QualifiedName` as a computed convenience list (a method rather than a - property, since a property returning a freshly-projected collection trips SonarAnalyzer S2365). - Each entry's `QualifiedName` is independently resolved by `ReferenceResolver` into a - `SysmlEdgeKind.Expose` edge, or an unresolved-reference diagnostic (and no edge) for that - entry. `GeneralViewLayoutStrategy` (via the shared `ExposeScopeResolver`) uses this to scope a - rendered diagram, and (Phase 2a) re-pairs each resolved edge back to its originating - `ExposeMember` to evaluate that entry's own `BracketFilterExpressionText`, if any, against that - entry's own containment subtree. Phase 1 originally captured each entry's bracket-filter text on - a separate, unpaired, flattened `ExposeBracketFilterTexts` list alongside an equally flattened - `ExposedNames` list, making it impossible to tell which exposed path a given bracket filter - belonged to when a view declared more than one `expose` member; `ExposeMembers` fixes this - defect by pairing the two together at capture time. + source order, paired as an + `ExposeMember(string QualifiedName, string? BracketFilterExpressionText, ExposeRecursionKind RecursionKind)` + record — the qualified-name reference text, that specific entry's own bracket-filter expression + text (or null when the entry carries none), and an `ExposeRecursionKind` classifying which SysML + v2 `expose` grammar form (MembershipExpose/NamespaceExpose) and recursion setting (a trailing + `::**`) produced the entry: `MembershipExact` (`expose X;`), `MembershipRecursive` + (`expose X::**;`), `NamespaceDirectChildren` (`expose X::*;`), or `NamespaceRecursive` + (`expose X::*::**;`) — a bracket-filtered entry is always classified as one of the two + *Recursive variants (matching its form), since `ExposeScopeResolver` only consults this + classification for its unfiltered/fallback whole-subtree-vs-narrow behavior, never for a + successfully-evaluated bracket filter's own already-exact `ExplicitMembers`. Empty when no + `expose` members are present (and always empty for a `view def` definition — `expose` is only + valid grammar inside a `view` usage's body). Extracted by `AstBuilder.ExtractExposedNames`, + sharing the same `ExtractImportTarget` helper `VisitImportRule` uses for plain `import`. + `GetExposedNames()` projects each entry's `QualifiedName` as a computed convenience list (a + method rather than a property, since a property returning a freshly-projected collection trips + SonarAnalyzer S2365). Each entry's `QualifiedName` is independently resolved by + `ReferenceResolver` into a `SysmlEdgeKind.Expose` edge, or an unresolved-reference diagnostic + (and no edge) for that entry. `GeneralViewLayoutStrategy` (via the shared `ExposeScopeResolver`) + uses this to scope a rendered diagram, and (Phase 2a) re-pairs each resolved edge back to its + originating `ExposeMember` to evaluate that entry's own `BracketFilterExpressionText`, if any, + against that entry's own containment subtree, and to read its `RecursionKind` to decide between + exact/direct-children/whole-subtree matching. Phase 1 originally captured each entry's + bracket-filter text on a separate, unpaired, flattened `ExposeBracketFilterTexts` list alongside + an equally flattened `ExposedNames` list, making it impossible to tell which exposed path a given + bracket filter belonged to when a view declared more than one `expose` member; `ExposeMembers` + fixes this defect by pairing the two (and now, the recursion kind) together at capture time. - `FilterExpressionText` — the raw source text of the view's `filter [];` member's bracketed expression, or null when absent. Captured verbatim by `AstBuilder` (using the original token spacing, not `RuleContext.GetText()`'s whitespace-stripped form) and never diff --git a/docs/gallery/README.md b/docs/gallery/README.md index c08c939..ca4e320 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -62,8 +62,10 @@ SVG: [`svg/DroneGeneralView.svg`](svg/DroneGeneralView.svg) The same `01-drone-general.sysml` model also declares a second, named `view` usage that adds an `expose Battery;` statement. Instead of the full workspace, the diagram -is scoped to just the `Battery` definition's containment subtree — demonstrating -that `expose` (not `render`) is what controls diagram content scope. See the user +is scoped to just the `Battery` definition — with no wrapping `QuadcopterDrone` folder, +since the bare package `QuadcopterDrone` was never itself named by the `expose` statement, +only `Battery` was — demonstrating that `expose` (not `render`) is what controls diagram +content scope. See the user guide's [Expose vs. Render](../user_guide/introduction.md#expose-vs-render-worked-examples) section for the full explanation and more worked examples. @@ -236,6 +238,70 @@ SVG: [`svg/MotorRigInterconnectionView.svg`](svg/MotorRigInterconnectionView.svg --- +## 10. Expose Recursion Semantics — Mission Control Hierarchy + +Shows all four `expose` recursion forms side by side on the same nested-package model +(`GroundSegment` contains a `RadioNetwork` sub-package with `Uplink`/`Downlink`, plus +`OperatorConsole` — itself owning nested `DisplayPanel`/`CommsHandset` definitions — and +a sibling leaf `ThermalRegulator`). See the user guide's +[Expose Recursion Semantics](../user_guide/introduction.md#expose-vs-render-worked-examples) +section for the full grammar/semantics explanation. + +`OperatorConsoleExactView` (`expose GroundSegment::OperatorConsole;`, MembershipExact — no +`::**`) exposes only `OperatorConsole` itself; its nested `DisplayPanel`/`CommsHandset` +are excluded. + +Model: [`models/10-mission-control-expose-recursion.sysml`](models/10-mission-control-expose-recursion.sysml) +(`OperatorConsoleExactView`) · SVG: [`svg/OperatorConsoleExactView.svg`](svg/OperatorConsoleExactView.svg) + +![Operator Console Exact View](png/OperatorConsoleExactView.png) + +`OperatorConsoleDeepView` (`expose GroundSegment::OperatorConsole::**;`, +MembershipRecursive) exposes `OperatorConsole` and its entire containment subtree: +`DisplayPanel` and `CommsHandset` are now included too. + +Model: [`models/10-mission-control-expose-recursion.sysml`](models/10-mission-control-expose-recursion.sysml) +(`OperatorConsoleDeepView`) · SVG: [`svg/OperatorConsoleDeepView.svg`](svg/OperatorConsoleDeepView.svg) + +![Operator Console Deep View](png/OperatorConsoleDeepView.png) + +`GroundSegmentDirectChildrenView` (`expose GroundSegment::*;`, NamespaceDirectChildren) +exposes only `GroundSegment`'s direct members, one level deep: `OperatorConsole` and +`ThermalRegulator` appear, but neither `OperatorConsole`'s own nested definitions nor +`RadioNetwork`'s nested `Uplink`/`Downlink` (both two levels below `GroundSegment`) do — +and `GroundSegment` itself is not included either. + +Model: [`models/10-mission-control-expose-recursion.sysml`](models/10-mission-control-expose-recursion.sysml) +(`GroundSegmentDirectChildrenView`) · +SVG: [`svg/GroundSegmentDirectChildrenView.svg`](svg/GroundSegmentDirectChildrenView.svg) + +![Ground Segment Direct Children View](png/GroundSegmentDirectChildrenView.png) + +`GroundSegmentRecursiveView` (`expose GroundSegment::*::**;`, NamespaceRecursive) exposes +every descendant of `GroundSegment`, recursively — `OperatorConsole`, `DisplayPanel`, +`CommsHandset`, `ThermalRegulator`, `Uplink`, and `Downlink` all appear. `RadioNetwork` +is a bare `package`, not a definition, so it is never itself rendered as a box; its members +`Uplink`/`Downlink` are in scope and render as flat top-level boxes with no `RadioNetwork` +wrapper. `GroundSegment` (also a bare package) is excluded from scope and, like +`RadioNetwork`, shows no folder either. + +Model: [`models/10-mission-control-expose-recursion.sysml`](models/10-mission-control-expose-recursion.sysml) +(`GroundSegmentRecursiveView`) · SVG: [`svg/GroundSegmentRecursiveView.svg`](svg/GroundSegmentRecursiveView.svg) + +![Ground Segment Recursive View](png/GroundSegmentRecursiveView.png) + +> **Note:** the `GroundSegment` folder from section 10's unscoped `MissionControlGeneralView` +> does **not** appear in any of the four scoped views above (`OperatorConsoleExactView`, +> `OperatorConsoleDeepView`, `GroundSegmentDirectChildrenView`, `GroundSegmentRecursiveView`). +> `GroundSegment` is a bare package — it is never itself admitted content, only an ancestor of +> whatever content each `expose` statement actually names — so once a view is scoped, General +> View no longer wraps that content in a folder for an ancestor the scope never referenced. +> The same fix applies to section 1b above: the `QuadcopterDrone` folder no longer appears +> around `Battery` in `BatterySubsystemView`, since `QuadcopterDrone` itself was never exposed — +> only `Battery` was. + +--- + ## View coverage | # | View type | Example system | Status | diff --git a/docs/gallery/models/10-mission-control-expose-recursion.sysml b/docs/gallery/models/10-mission-control-expose-recursion.sysml new file mode 100644 index 0000000..d980c4b --- /dev/null +++ b/docs/gallery/models/10-mission-control-expose-recursion.sysml @@ -0,0 +1,68 @@ +package MissionControl { + + // A three-level-deep containment hierarchy, entirely through nested packages and + // definitions (never through usage/type references), so `expose`'s qualified-name-prefix + // scoping applies exactly as intended at every level. + package GroundSegment { + package RadioNetwork { + part def Uplink; + part def Downlink; + } + + // OperatorConsole owns its own nested definitions, so a MembershipExact vs. + // MembershipRecursive expose targeting it directly (rather than the package) has + // real, visible content to include or omit. + part def OperatorConsole { + part def DisplayPanel; + part def CommsHandset; + } + + // A second direct child of GroundSegment (a leaf, no nested definitions of its + // own) so the NamespaceDirectChildren view has more than one box to show — + // distinguishing it from a MembershipExact expose of OperatorConsole alone. + part def ThermalRegulator; + } + + package SpaceSegment { + part def Payload; + } + + view def MissionControlGeneralView {} + + // ===== The four `expose` recursion forms, side by side on the same model ===== + // + // See the user guide's "Expose Recursion Semantics" section for the full explanation. + // All four views below scope the same nested model differently, purely by varying the + // expose statement's recursion form: + + // `expose ;` (MembershipExact) — exposes only the named element itself: + // OperatorConsole's own box, with neither DisplayPanel nor CommsHandset. The single + // most restrictive form. + view OperatorConsoleExactView { + expose GroundSegment::OperatorConsole; + } + + // `expose ::**;` (MembershipRecursive) — exposes the named element and its + // entire containment subtree, recursively: OperatorConsole plus its nested + // DisplayPanel and CommsHandset definitions. + view OperatorConsoleDeepView { + expose GroundSegment::OperatorConsole::**; + } + + // `expose ::*;` (NamespaceDirectChildren) — exposes only the direct members of + // the named namespace, one level deep. GroundSegment itself is NOT included; its + // direct children OperatorConsole and ThermalRegulator are, but OperatorConsole's own + // nested DisplayPanel and CommsHandset (two levels below GroundSegment) are not, and + // neither are RadioNetwork's nested Uplink/Downlink (also two levels below). + view GroundSegmentDirectChildrenView { + expose GroundSegment::*; + } + + // `expose ::*::**;` (NamespaceRecursive) — exposes every descendant of the + // named namespace, recursively, but (unlike MembershipRecursive) still excludes the + // namespace itself. OperatorConsole, DisplayPanel, CommsHandset, ThermalRegulator, + // Uplink, and Downlink are all in scope; GroundSegment's own folder is not. + view GroundSegmentRecursiveView { + expose GroundSegment::*::**; + } +} diff --git a/docs/gallery/png/AvionicsBrowserView.png b/docs/gallery/png/AvionicsBrowserView.png index 91b6b1c..628a415 100644 Binary files a/docs/gallery/png/AvionicsBrowserView.png and b/docs/gallery/png/AvionicsBrowserView.png differ diff --git a/docs/gallery/png/BatterySubsystemView.png b/docs/gallery/png/BatterySubsystemView.png index 7a6a38c..2bbafc9 100644 Binary files a/docs/gallery/png/BatterySubsystemView.png and b/docs/gallery/png/BatterySubsystemView.png differ diff --git a/docs/gallery/png/CarLineageGridView.png b/docs/gallery/png/CarLineageGridView.png index e7a158e..3cbf809 100644 Binary files a/docs/gallery/png/CarLineageGridView.png and b/docs/gallery/png/CarLineageGridView.png differ diff --git a/docs/gallery/png/ComputerSystemInterconnectionView.png b/docs/gallery/png/ComputerSystemInterconnectionView.png index f45e52b..992c783 100644 Binary files a/docs/gallery/png/ComputerSystemInterconnectionView.png and b/docs/gallery/png/ComputerSystemInterconnectionView.png differ diff --git a/docs/gallery/png/CoreLinkInterconnectionView.png b/docs/gallery/png/CoreLinkInterconnectionView.png index 84e3c66..a3c18e9 100644 Binary files a/docs/gallery/png/CoreLinkInterconnectionView.png and b/docs/gallery/png/CoreLinkInterconnectionView.png differ diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png index 05e5409..9f788dd 100644 Binary files a/docs/gallery/png/DroneGeneralView.png and b/docs/gallery/png/DroneGeneralView.png differ diff --git a/docs/gallery/png/GroundSegmentDirectChildrenView.png b/docs/gallery/png/GroundSegmentDirectChildrenView.png new file mode 100644 index 0000000..069d11b Binary files /dev/null and b/docs/gallery/png/GroundSegmentDirectChildrenView.png differ diff --git a/docs/gallery/png/GroundSegmentRecursiveView.png b/docs/gallery/png/GroundSegmentRecursiveView.png new file mode 100644 index 0000000..99e1213 Binary files /dev/null and b/docs/gallery/png/GroundSegmentRecursiveView.png differ diff --git a/docs/gallery/png/MissionControlGeneralView.png b/docs/gallery/png/MissionControlGeneralView.png new file mode 100644 index 0000000..384b38a Binary files /dev/null and b/docs/gallery/png/MissionControlGeneralView.png differ diff --git a/docs/gallery/png/OAuthSequenceView.png b/docs/gallery/png/OAuthSequenceView.png index d38ad0a..555c85a 100644 Binary files a/docs/gallery/png/OAuthSequenceView.png and b/docs/gallery/png/OAuthSequenceView.png differ diff --git a/docs/gallery/png/OperatorConsoleDeepView.png b/docs/gallery/png/OperatorConsoleDeepView.png new file mode 100644 index 0000000..40845bc Binary files /dev/null and b/docs/gallery/png/OperatorConsoleDeepView.png differ diff --git a/docs/gallery/png/OperatorConsoleExactView.png b/docs/gallery/png/OperatorConsoleExactView.png new file mode 100644 index 0000000..81761a7 Binary files /dev/null and b/docs/gallery/png/OperatorConsoleExactView.png differ diff --git a/docs/gallery/png/TaxonomyMatrixView.png b/docs/gallery/png/TaxonomyMatrixView.png index 5599af4..6238513 100644 Binary files a/docs/gallery/png/TaxonomyMatrixView.png and b/docs/gallery/png/TaxonomyMatrixView.png differ diff --git a/docs/gallery/png/TokenExchangeSequenceView.png b/docs/gallery/png/TokenExchangeSequenceView.png index b576a48..44b83b9 100644 Binary files a/docs/gallery/png/TokenExchangeSequenceView.png and b/docs/gallery/png/TokenExchangeSequenceView.png differ diff --git a/docs/gallery/png/WorkstationInterconnectionView.png b/docs/gallery/png/WorkstationInterconnectionView.png index 1535109..5dc0764 100644 Binary files a/docs/gallery/png/WorkstationInterconnectionView.png and b/docs/gallery/png/WorkstationInterconnectionView.png differ diff --git a/docs/gallery/svg/BatterySubsystemView.svg b/docs/gallery/svg/BatterySubsystemView.svg index ea5846e..4ae5279 100644 --- a/docs/gallery/svg/BatterySubsystemView.svg +++ b/docs/gallery/svg/BatterySubsystemView.svg @@ -1,4 +1,4 @@ - + @@ -30,23 +30,19 @@ - - «package» - QuadcopterDrone - - «part def» - Battery - - attributes - capacity : Voltage - - ports - output : PowerPort - - - - Rechargeable lithium-polymer battery pack supplying the drone's - * flight controller, motors, and sensor bus via the PowerPort - * interface. Demonstrates the BoxShape.Note annotation rendering. - + + «part def» + Battery + + attributes + capacity : Voltage + + ports + output : PowerPort + + + Rechargeable lithium-polymer battery pack supplying the drone's + * flight controller, motors, and sensor bus via the PowerPort + * interface. Demonstrates the BoxShape.Note annotation rendering. + diff --git a/docs/gallery/svg/ComputerSystemInterconnectionView.svg b/docs/gallery/svg/ComputerSystemInterconnectionView.svg index 60f98b9..677e8be 100644 --- a/docs/gallery/svg/ComputerSystemInterconnectionView.svg +++ b/docs/gallery/svg/ComputerSystemInterconnectionView.svg @@ -1,4 +1,4 @@ - + @@ -30,40 +30,40 @@ - + «part def» Computer - + «part» board : Motherboard - + «part» cpu : Cpu - + «part» chipset : Chipset - + «part» ram : Ram - - - - - - - - «part» - psu : PowerSupply - - «part» - storage : Storage - - - - - - - - - + + + + + + + + «part» + psu : PowerSupply + + «part» + storage : Storage + + + + + + + + + diff --git a/docs/gallery/svg/CoreLinkInterconnectionView.svg b/docs/gallery/svg/CoreLinkInterconnectionView.svg index f2d4913..94998f4 100644 --- a/docs/gallery/svg/CoreLinkInterconnectionView.svg +++ b/docs/gallery/svg/CoreLinkInterconnectionView.svg @@ -1,4 +1,4 @@ - + @@ -30,16 +30,16 @@ - + «part def» Workstation - + «part» cpu : Cpu - + «part» memory : Ram - - - + + + diff --git a/docs/gallery/svg/DroneGeneralView.svg b/docs/gallery/svg/DroneGeneralView.svg index 4a2c150..8869988 100644 --- a/docs/gallery/svg/DroneGeneralView.svg +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -76,7 +76,6 @@ output : PowerPort - Rechargeable lithium-polymer battery pack supplying the drone's * flight controller, motors, and sensor bus via the PowerPort * interface. Demonstrates the BoxShape.Note annotation rendering. diff --git a/docs/gallery/svg/GroundSegmentDirectChildrenView.svg b/docs/gallery/svg/GroundSegmentDirectChildrenView.svg new file mode 100644 index 0000000..1afe19f --- /dev/null +++ b/docs/gallery/svg/GroundSegmentDirectChildrenView.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + OperatorConsole + + «part def» + ThermalRegulator + diff --git a/docs/gallery/svg/GroundSegmentRecursiveView.svg b/docs/gallery/svg/GroundSegmentRecursiveView.svg new file mode 100644 index 0000000..137a461 --- /dev/null +++ b/docs/gallery/svg/GroundSegmentRecursiveView.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + Uplink + + «part def» + Downlink + + «part def» + OperatorConsole + + «part def» + DisplayPanel + + «part def» + CommsHandset + + «part def» + ThermalRegulator + diff --git a/docs/gallery/svg/MissionControlGeneralView.svg b/docs/gallery/svg/MissionControlGeneralView.svg new file mode 100644 index 0000000..4a3b279 --- /dev/null +++ b/docs/gallery/svg/MissionControlGeneralView.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «package» + RadioNetwork + + «part def» + Uplink + + «part def» + Downlink + + «package» + GroundSegment + + «part def» + OperatorConsole + + «part def» + DisplayPanel + + «part def» + CommsHandset + + «part def» + ThermalRegulator + + «package» + SpaceSegment + + «part def» + Payload + diff --git a/docs/gallery/svg/OperatorConsoleDeepView.svg b/docs/gallery/svg/OperatorConsoleDeepView.svg new file mode 100644 index 0000000..eb82c78 --- /dev/null +++ b/docs/gallery/svg/OperatorConsoleDeepView.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + OperatorConsole + + «part def» + DisplayPanel + + «part def» + CommsHandset + diff --git a/docs/gallery/svg/OperatorConsoleExactView.svg b/docs/gallery/svg/OperatorConsoleExactView.svg new file mode 100644 index 0000000..7a2e8c5 --- /dev/null +++ b/docs/gallery/svg/OperatorConsoleExactView.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + OperatorConsole + diff --git a/docs/gallery/svg/WorkstationInterconnectionView.svg b/docs/gallery/svg/WorkstationInterconnectionView.svg index a6af042..212feb1 100644 --- a/docs/gallery/svg/WorkstationInterconnectionView.svg +++ b/docs/gallery/svg/WorkstationInterconnectionView.svg @@ -1,4 +1,4 @@ - + @@ -30,52 +30,52 @@ - + «part def» Workstation - + «part» cpu : Cpu - - «part» - memory : Ram - - «part» - graphics : Gpu - - «part» - storage : Ssd - - «part» - psu : PowerSupply - - «part» - network : NetworkCard - - «part» - board : Motherboard - - - - - - - - - - - - - - - - - - - - - - - - + + «part» + memory : Ram + + «part» + graphics : Gpu + + «part» + storage : Ssd + + «part» + psu : PowerSupply + + «part» + network : NetworkCard + + «part» + board : Motherboard + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml index 1853b60..5c8e4c9 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml @@ -52,18 +52,86 @@ sections: - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-SubjectScopeMembership title: >- ExposeScopeResolver.IsInSubjectScope shall return true for a qualified name that - exactly matches a subject or lies within a subject's containment subtree (a - "{subject}::" prefix match), and false otherwise. + matches a subject per that subject's own ExposeRecursionKind — an exact match for + MembershipExact, a direct (one-level) child match for NamespaceDirectChildren, or an + exact match or containment-subtree match (a "{subject}::" prefix match) for + MembershipRecursive/NamespaceRecursive — and false otherwise. justification: | Every strategy needs a single, consistent rule for "is this element within the exposed - scope" that treats an exposed target's entire containment subtree — not just the target - itself — as included, matching how SysML v2 namespaces nest qualified names. + scope" that honors each `expose` entry's own SysML v2 grammar form and recursion + setting (`expose X;`/`expose X::**;`/`expose X::*;`/`expose X::*::**;`), rather than + unconditionally treating an exposed target's entire containment subtree as included + regardless of whether the entry actually requested recursion. tests: - IsInSubjectScope_ExactMatch_ReturnsTrue - IsInSubjectScope_SubtreeMatch_ReturnsTrue - IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse - IsInSubjectScope_UnrelatedName_ReturnsFalse + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-MembershipExactNarrowScope + title: >- + ExposeScopeResolver.ResolveExposedScope shall resolve a non-recursive MembershipExpose + entry (expose X;) to a scope containing only X itself (and, for a usage target, its + resolved type, itself only) — not X's containment subtree. + justification: | + Per formal-26-03-02.md §8.3.26.2-4, a bare `expose X;` (MembershipExpose with no + trailing `::**`) is non-recursive: only X is exposed, not its nested members. Treating a + bare `expose X;` as whole-subtree inclusion (the pre-fix behavior) silently over-scoped + every non-recursive membership expose to its entire subtree, which is a correctness bug, + not a rendering nicety. + tests: + - ResolveExposedScope_MembershipExact_ScopeIsExactOnly + - ResolveExposedScope_MembershipExactUsage_TypeFallbackIsAlsoExactOnly + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-NamespaceDirectChildrenNarrowScope + title: >- + ExposeScopeResolver.ResolveExposedScope shall resolve a non-recursive NamespaceExpose + entry (expose X::*;) to a scope containing only X's direct (one-level) children — not X + itself and not deeper descendants. + justification: | + Per formal-26-03-02.md §8.3.26.2-4, a non-recursive `expose X::*;` (NamespaceExpose with + no trailing `::**`) exposes only X's direct members, not X itself and not their own + nested members. Treating this form as whole-subtree inclusion (the pre-fix behavior) was + incorrect on two counts: it wrongly included X itself, and it wrongly included + grandchildren and deeper descendants. + tests: + - ResolveExposedScope_NamespaceDirectChildren_ScopeIsDirectChildrenOnly + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-RecursiveWholeSubtreeUnchanged + title: >- + ExposeScopeResolver.ResolveExposedScope shall continue to resolve a recursive + MembershipExpose entry (expose X::**;) to a scope containing X and its entire + containment subtree, and a bracket-filtered entry that fails to parse or evaluate shall + continue to fall back to this same whole-subtree behavior regardless of the entry's own + recursion kind. + justification: | + Recursive `expose` forms (`::**`) are the one case where the pre-fix whole-subtree + behavior was already correct; this requirement is a regression guard confirming the fix + for the non-recursive forms above did not also narrow the recursive forms or the + bracket-filter-failure fallback, which must still degrade to the full subtree rather + than the narrower Exact/DirectChildren behavior (never silently losing content on a + bracket-filter failure). + tests: + - ResolveExposedScope_MembershipRecursive_ScopeIsWholeSubtree + - ResolveExposedScope_BracketFilterFailure_AlwaysFallsBackToRecursive + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-NamespaceRecursiveExcludesSubject + title: >- + ExposeScopeResolver.ResolveExposedScope shall resolve a recursive NamespaceExpose entry + (expose X::*::**;) to a scope containing X's entire containment subtree excluding X + itself, mirroring the non-recursive NamespaceExpose (expose X::*;) exclusion of X but + extending it to descendants at every depth rather than direct children only. + justification: | + Per the OMG SysML v2 specification, a NamespaceExpose never includes the namespace + being exposed, regardless of whether the recursive (`::**`) or direct-children-only + (`::*`) form is used — only MembershipExpose (`expose X;`/`expose X::**;`) includes the + subject itself. A prior implementation incorrectly treated NamespaceRecursive + identically to MembershipRecursive (including the subject); this requirement guards + against that regression recurring. + tests: + - ResolveExposedScope_NamespaceRecursive_ScopeIsDescendantsOnlyExcludingSubject + - ResolveExposedScope_NamespaceRecursiveOnDefinition_ExcludesDefinitionItselfIncludesDescendants + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-RootRelevance title: >- ExposeScopeResolver.IsRootRelevantToScope shall return true for a candidate root 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 7527fcb..d24fd46 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 @@ -23,14 +23,47 @@ sections: - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-PackageGrouping title: >- - GeneralViewLayoutStrategy shall group the definitions that belong to a package within a - folder-shaped container box labeled with the package name. + When the view has no resolved expose scope, GeneralViewLayoutStrategy shall group the + definitions that belong to a bare package within a folder-shaped container box labeled + with the package name. justification: | Grouping a package's definitions inside a labeled folder communicates ownership and keeps related definitions visually together, which is essential for readable diagrams - of larger models. + of larger models, for the unscoped (whole-workspace) case where every ancestor package + is genuinely part of what the view renders. tests: - GeneralViewLayoutStrategy_BuildLayout_PackagedDefinitions_ProducesFolderBox + - GeneralViewLayoutStrategy_BuildLayout_Unscoped_StillRendersFullPackageFolderStructure + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-NestedDefinitionContainment + title: >- + GeneralViewLayoutStrategy shall render a definition that owns nested definitions as a + single box, with its nested definitions placed inside that box rather than as a + duplicate sibling container. + justification: | + A definition that owns nested definitions (for example a part def containing further + part def declarations) is one model element and must appear as exactly one box. + Rendering it a second time as a separate sibling folder holding its own children is a + duplicate-content defect that misrepresents the model's containment structure and + confuses the reader. + tests: + - GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxUnscoped + - GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxScoped + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-ScopedBarePackageFolderSuppression + title: >- + When the view has a resolved expose scope, GeneralViewLayoutStrategy shall not render a + bare package as a wrapping folder merely because it is an ancestor of admitted content; + the package's admitted items shall be promoted directly to the diagram's root instead. + justification: | + An expose statement deliberately narrows a view to specific content. Wrapping that + content's bare-package ancestor in a folder anyway — even though the package itself was + never referenced by the expose scope — draws a container the scope did not request and + misleadingly suggests the whole package is in view. + tests: + - GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxScoped + - GeneralViewLayoutStrategy_BuildLayout_ExposedNamespaceChildren_BarePackageAncestor_NoFolderRendered + - GeneralViewLayoutStrategy_BuildLayout_ExposedDefinitionInsideBarePackage_NoAncestorFolderRendered - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Compartments title: >- diff --git a/docs/reqstream/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.yaml b/docs/reqstream/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.yaml index 9362030..5e9a9e1 100644 --- a/docs/reqstream/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.yaml +++ b/docs/reqstream/sysml2-tools-core/rendering/internal/dynamic-view-synthesizer.yaml @@ -76,15 +76,17 @@ sections: title: >- DynamicViewSynthesizer shall construct a synthesized view node with a reserved, collision-checked qualified name, a RenderTargetName set to the resolved token, a - single Expose edge and ExposeMembers entry scoping the view to exactly the requested - target, and a FilterExpressionText passed through unchanged from --filter, reporting a - diagnostic instead of silently overwriting an existing declaration on the rare name - collision. + single Expose edge and ExposeMembers entry (using ExposeRecursionKind.MembershipRecursive) + scoping the view to the requested target's whole containment subtree, and a + FilterExpressionText passed through unchanged from --filter, reporting a diagnostic + instead of silently overwriting an existing declaration on the rare name collision. justification: | - Scoping the synthesized view to exactly its target (rather than the whole workspace, - as SynthesizeAutoView does) is the key behavior that makes a dynamic view usable for a - large model. The reserved, collision-checked name prevents any possibility of - corrupting an existing declaration. + Scoping the synthesized view to the target's containment subtree (rather than the + whole workspace, as SynthesizeAutoView does) is the key behavior that makes a dynamic + view usable for a large model — showing the requested element together with its full + nested context, matching the intended "inspect this element and everything under it" + use case. The reserved, collision-checked name prevents any possibility of corrupting + an existing declaration. tests: - Synthesize_FilterExpression_PassedThroughUnchanged - Synthesize_NoFilterExpression_ResultsInNullFilterExpressionText 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 2dc58f5..b7ba674 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -84,9 +84,10 @@ sections: - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ViewExposedNames title: >- AstBuilder shall capture each `expose [::**[]];` member (`view` usages - only, in source order) as a paired `ExposeMember` — qualified-name reference text plus - its own bracket-filter expression text (or null when absent) — on the corresponding - SysmlViewNode's ExposeMembers property. + only, in source order) as a paired `ExposeMember` — qualified-name reference text, + its own bracket-filter expression text (or null when absent), and its + `ExposeRecursionKind` classification — on the corresponding SysmlViewNode's + ExposeMembers property. justification: | Capturing this data during AST construction is the prerequisite for ReferenceResolver to resolve each entry's qualified name into edges that GeneralViewLayoutStrategy uses @@ -95,7 +96,9 @@ sections: text (rather than the earlier Phase 1 flattened, unpaired `ExposedNames`/ `ExposeBracketFilterTexts` lists) is the Phase 2a prerequisite for `ExposeScopeResolver` to evaluate a bracket filter against the specific path it was - declared on, when a view declares more than one `expose` member. + declared on, when a view declares more than one `expose` member; pairing each entry's + recursion kind alongside it is the prerequisite for `ExposeScopeResolver` to correctly + distinguish exact/direct-children/whole-subtree matching per entry. tests: - WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge - WorkspaceLoader_LoadAsync_ViewUsageWithBracketedFilterExpose_RecordsExposeEdge @@ -103,6 +106,29 @@ sections: - WorkspaceLoader_LoadAsync_ViewEmptyBody_AllNewFieldsNullOrEmpty - AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ExposeRecursionKindDerivation + title: >- + AstBuilder shall classify each `expose` member's ExposeRecursionKind correctly per its + SysML v2 grammar form (MembershipExpose/NamespaceExpose) and recursion setting (a + trailing `::**`): `expose X;` as MembershipExact, `expose X::**;` as + MembershipRecursive, `expose X::*;` as NamespaceDirectChildren, and `expose X::*::**;` + as NamespaceRecursive, fixing the prior defect where `ExtractImportTarget`'s + namespace-import branch hard-coded its recursion bit away instead of checking for a + trailing `::**`. + justification: | + Per formal-26-03-02.md §8.3.26.2-4, MembershipExpose and NamespaceExpose each carry + their own independent `isRecursive` flag; conflating every non-bracket-filtered + `expose` entry into unconditional whole-subtree inclusion (the pre-fix behavior) + silently over-scoped every non-recursive `expose` statement in every rendered + diagram. Correct per-form, per-recursion classification during AST construction is + the prerequisite for `ExposeScopeResolver`'s corresponding scope-matching fix. + tests: + - AstBuilder_ExposeBareMembership_CapturesMembershipExact + - AstBuilder_ExposeRecursiveMembership_CapturesMembershipRecursive + - AstBuilder_ExposeNamespaceDirectChildren_CapturesNamespaceDirectChildren + - AstBuilder_ExposeNamespaceRecursive_CapturesNamespaceRecursive + - WorkspaceLoader_LoadAsync_OmgSafetyFeatureViewsFixture_ResolvesBracketedExpose + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ViewUsageExpose title: >- AstBuilder shall build a SysmlViewNode from a named `view` usage (in addition to a 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 c9a6af0..9f781e7 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/sysml-node.yaml @@ -44,9 +44,10 @@ sections: - id: SysML2Tools-Language-Semantic-Model-SysmlNode-ViewExposedNames title: >- SysmlViewNode shall expose ExposeMembers: each `expose [::**[]];` member's - qualified-name reference text paired with its own bracket-filter expression text (or - null when the entry carries no bracket filter), in source order; GetExposedNames() - shall return the qualified name of each entry as a computed convenience projection. + qualified-name reference text, its own bracket-filter expression text (or null when the + entry carries no bracket filter), and its `ExposeRecursionKind` classification, in + source order; GetExposedNames() shall return the qualified name of each entry as a + computed convenience projection. justification: | Making a view's `expose` statements user-observable data is the prerequisite for ReferenceResolver, which resolves GetExposedNames() into `Expose`-kind edges that @@ -56,12 +57,19 @@ sections: bracket filter belonged to when a view declared more than one `expose` member; Phase 2a fixes this by pairing each entry's path and bracket-filter text together as a single `ExposeMember`, the prerequisite for `ExposeScopeResolver` to evaluate each entry's - bracket filter against that entry's own containment subtree. + bracket filter against that entry's own containment subtree. This fix additionally adds + the `ExposeRecursionKind` classification, the prerequisite for `ExposeScopeResolver` to + correctly distinguish exact/direct-children/whole-subtree matching per entry instead of + unconditionally treating every entry as whole-subtree. tests: - WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge - WorkspaceLoader_LoadAsync_ViewEmptyBody_AllNewFieldsNullOrEmpty - AstBuilder_ExposeBracketFilter_CapturesRawText - AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath + - AstBuilder_ExposeBareMembership_CapturesMembershipExact + - AstBuilder_ExposeRecursiveMembership_CapturesMembershipRecursive + - AstBuilder_ExposeNamespaceDirectChildren_CapturesNamespaceDirectChildren + - AstBuilder_ExposeNamespaceRecursive_CapturesNamespaceRecursive - id: SysML2Tools-Language-Semantic-Model-SysmlNode-ViewFilterExpression title: >- diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 6362282..2593f80 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -170,21 +170,35 @@ view kind applies), `expose` now scopes the rendered diagram instead of always r entire workspace: - `expose ;` (valid only inside a named `view` usage's body, not a `view def` - definition's body) — scopes the diagram to the union of every exposed name's containment - subtree: `` plus every declaration whose qualified name is `` or is contained - within it (a containment-subtree match, not just the exact element). If `` does not - resolve to any declaration in the workspace (for example, a typo), the tool falls back to - rendering the full workspace for that view — but now also reports a diagnostic identifying the - unresolved name, so the mistake is visible instead of silently rendering everything with no - signal. The bracket-filter form `expose ::**[];` is now (Phase 2a) **evaluated** - using the same supported subset described below for standalone `filter ;`: when the - bracketed expression parses and evaluates successfully, that entry narrows to only the matched - descendant definitions within ``'s own containment subtree, instead of the whole - subtree — each `expose` entry in a view is evaluated independently, so one bracket-filtered - entry's narrowing never affects any other `expose` entry in the same view. A bracket - expression that fails to parse or falls outside the supported subset degrades gracefully to - the previous whole-subtree behavior for that entry, with a diagnostic identifying the failed - expression and reason. + definition's body) — per the SysML v2 grammar, `expose` has four distinct forms with + independent scoping behavior, driven by whether the grammar alternative is + MembershipExpose or NamespaceExpose and whether a trailing `::**` requests recursion: + - `expose X;` (bare MembershipExpose) — scopes to **`X` itself only**, not its containment + subtree. If `X` resolves to a usage (e.g. `part myVehicle : Vehicle;`) rather than a + definition, its resolved type (`Vehicle`) is also included, itself only (not the type's + subtree either). + - `expose X::**;` (recursive MembershipExpose) — scopes to `X` **and its entire containment + subtree**: `X` plus every declaration whose qualified name is `X` or is contained within it. + - `expose X::*;` (bare NamespaceExpose) — scopes to only `X`'s **direct (one-level) children**, + not `X` itself and not deeper descendants. + - `expose X::*::**;` (recursive NamespaceExpose) — like `expose X::*;`, still **excludes `X` + itself** (a NamespaceExpose only ever exposes `X`'s Memberships — its members — never `X` as + a member of itself), but additionally includes descendants beyond direct children, at any + depth — unlike `NamespaceDirectChildren`, which stops at one level. + + If `X` does not resolve to any declaration in the workspace (for example, a typo), the tool + falls back to rendering the full workspace for that view — but now also reports a diagnostic + identifying the unresolved name, so the mistake is visible instead of silently rendering + everything with no signal. The bracket-filter form `expose ::**[];` is now + (Phase 2a) **evaluated** using the same supported subset described below for standalone + `filter ;`: when the bracketed expression parses and evaluates successfully, that entry + narrows to only the matched descendant definitions within ``'s own containment + subtree, instead of the whole subtree — each `expose` entry in a view is evaluated + independently, so one bracket-filtered entry's narrowing never affects any other `expose` + entry in the same view. A bracket expression that fails to parse or falls outside the + supported subset degrades gracefully to whole-subtree inclusion for that entry (regardless of + whether the entry's own form was otherwise non-recursive), with a diagnostic identifying the + failed expression and reason. - `render ;` — per the SysML v2 grammar, this names a rendering style/format (e.g. `asTreeDiagram`, `asElementTable`). `render asTreeDiagram;`, `render asInterconnectionDiagram;`, `render asGeneralDiagram;`, `render asStateTransitionDiagram;`, @@ -255,7 +269,7 @@ of confusion, so it is worth stating plainly: | `render ;` | Selects a rendering style — see "View Body Statements" above. Never scopes content. | | `filter ;` | Narrows scope (Phase 1, and Phase 2a per bracketed `expose`); unsupported falls back unfiltered. | -### Example A: exposing a definition to scope down to a subsystem +### Example A: exposing a definition — exact vs. recursive ```sysml package Vehicle { @@ -272,16 +286,30 @@ package Vehicle { expose Engine; render asTreeDiagram; } + + view EngineRecursiveView { + expose Engine::**; + render asTreeDiagram; + } } ``` -`EngineOnlyView` declares `render asTreeDiagram;`, so it renders via `BrowserViewLayoutStrategy` -as an indented tree of rows rather than the General View's nested boxes. It renders **only** the -`Engine` definition's containment subtree (`Engine` and its `cylinder` part) — `Vehicle`, -`myVehicle`, and `wheel` are excluded entirely. Removing the `expose Engine;` statement (leaving -only `render asTreeDiagram;`, or an empty view body) renders the **full workspace** instead: -`render` never narrows the scope, only `expose` does — `BrowserViewLayoutStrategy` honors -`expose` scoping identically to every other layout strategy. +Both views declare `render asTreeDiagram;`, so they render via `BrowserViewLayoutStrategy` as an +indented tree of rows rather than the General View's nested boxes. `render` never narrows the +scope, only `expose` does — `BrowserViewLayoutStrategy` honors `expose` scoping identically to +every other layout strategy. + +- `EngineOnlyView`'s bare `expose Engine;` is non-recursive (`MembershipExact`): it renders + **only the `Engine` definition itself** — `cylinder` is **not** included, since a bare + `expose X;` no longer implies the whole containment subtree. Confirmed by hand-rendering this + exact fixture: the tree contains a single row, `Engine`. +- `EngineRecursiveView`'s `expose Engine::**;` is recursive (`MembershipRecursive`): it renders + `Engine`'s **entire containment subtree** — `Engine` and its `cylinder` part (two rows) — the + unchanged whole-subtree behavior from before this fix. + +In both views, `Vehicle`, `myVehicle`, and `wheel` are excluded entirely, since neither `Engine` +nor its subtree contains them. Removing the `expose` statement (leaving only `render +asTreeDiagram;`, or an empty view body) renders the **full workspace** instead. > **Note:** `expose` targets are qualified names (`::`-separated), not dotted member-access > chains. `expose myVehicle.engine;` is a **syntax error**, not merely an unresolved reference — @@ -308,19 +336,27 @@ package Vehicle { } ``` -Here `expose myVehicle;` names a **usage** (`myVehicle : Vehicle`), not a `def`. The tool -resolves `myVehicle`'s own `Typing` edge to find the definition it is typed by (`Vehicle`), and -scopes the diagram to the union of `myVehicle`'s and `Vehicle`'s containment subtrees. This view -also declares `render asTreeDiagram;`, so — like Example A — it renders via -`BrowserViewLayoutStrategy` as an indented tree of rows. The rendered tree therefore includes -`Vehicle` (with its `engine` and `wheel` parts) and, because `engine` is typed by `Engine`, the -`Engine` definition (with its `cylinder` part) as well. - -Contrast this with `expose Vehicle;` (exposing the **definition** directly, as in Example A): -that scopes straight to `Vehicle`'s own containment subtree without needing to resolve any -`Typing` edge, since a definition's subtree is already the thing being scoped to. Exposing a -usage takes one extra hop — through the usage's type reference — to arrive at the same kind of -definition subtree that exposing a `def` reaches directly. +Here `expose myVehicle;` names a **usage** (`myVehicle : Vehicle`), not a `def`, and is +non-recursive (`MembershipExact`). The tool resolves `myVehicle`'s own `Typing` edge to find the +definition it is typed by (`Vehicle`), and adds that resolved type to the scope too — using the +**same** exact-match (not whole-subtree) recursion kind, since the usage's own expose was itself +non-recursive. Confirmed by hand-rendering this exact fixture: the tree contains the `myVehicle` +row and the `Vehicle` row, but neither `Vehicle`'s own `engine`/`wheel` parts nor `Engine`'s +`cylinder` are included, because exact-match scoping does not pull in either exposed name's +descendants. + +To render `myVehicle`'s and `Vehicle`'s full nested structure instead, expose recursively — +`expose myVehicle::**;` — which scopes to the union of `myVehicle`'s and `Vehicle`'s entire +containment subtrees (unchanged whole-subtree behavior), including `engine`, `wheel`, and (via +`engine`'s own type) `Engine`'s `cylinder` part. + +Contrast this with `expose Vehicle;` (exposing the **definition** directly): that scopes to just +`Vehicle` itself (exact match), without needing to resolve any `Typing` edge, since a +definition's own qualified name is already the exact-match subject. Exposing a usage takes one +extra hop — through the usage's type reference — to add the same kind of definition to the scope +that exposing a `def` reaches directly; in both cases, recursion (`::**`) is what controls +whether descendants are included, independent of whether the initial target was a usage or a +definition. ## Depth Limiting diff --git a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md index 921a40e..29c2659 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -5,7 +5,7 @@ `ExposeScopeResolver` is verified through direct unit tests in `ExposeScopeResolverTests` that call `ResolveExposedScope`, `IsInSubjectScope`, `IsRootRelevantToScope`, and `IsMoreSpecificCandidate` directly with synthetic `SysmlWorkspace`/`SysmlViewNode` inputs and -assert on the returned `ExposedScope` (Phase 2a: `PrefixSubjects`/`ExplicitMembers`/`Failures`, +assert on the returned `ExposedScope` (Phase 2a: `Subjects`/`ExplicitMembers`/`Failures`, replacing the earlier flat qualified-name-list shape) or boolean result. No mocking is required; every method is a pure function over its parameters. @@ -20,38 +20,52 @@ configuration are required beyond a standard .NET SDK installation. - A `null` `ViewNode` resolves to a `null` scope. - A `ViewNode` with no resolved `Expose`-kind edges resolves to a `null` scope. - A resolved `Expose` edge targeting a definition resolves to an `ExposedScope` whose - `PrefixSubjects` contains exactly that definition's qualified name. + `Subjects` contains exactly that definition's qualified name. - A resolved `Expose` edge targeting a feature usage additionally includes that usage's own - resolved `Typing` edge target qualified name in `PrefixSubjects`. + resolved `Typing` edge target qualified name in `Subjects`. - Two resolved `Expose` edges on the same view — one targeting a plain definition, one targeting a - feature usage — union both targets plus the usage's resolved type into `PrefixSubjects`. + feature usage — union both targets plus the usage's resolved type into `Subjects`. - (Phase 2a) An `expose ::**[];` entry whose bracket-filter expression parses and evaluates successfully narrows to only the matched descendant `SysmlDefinitionNode`s under that entry's containment subtree, added to `ExposedScope.ExplicitMembers` — the target itself is - *not* added to `PrefixSubjects` (no whole-subtree fallback for a successfully-evaluated entry). + *not* added to `Subjects` (no whole-subtree fallback for a successfully-evaluated entry). - (Phase 2a) Two `expose` entries on the same view, only one bracket-filtered, narrow - independently: the unfiltered entry keeps its whole containment subtree in `PrefixSubjects` + independently: the unfiltered entry keeps its whole containment subtree in `Subjects` while the bracket-filtered entry narrows to only its own matched members in `ExplicitMembers`. - (Phase 2a) A bracket-filter expression that fails to parse or evaluate degrades gracefully to - whole-subtree inclusion for that entry (added to `PrefixSubjects`, same as the unfiltered case) + whole-subtree inclusion for that entry (added to `Subjects`, same as the unfiltered case) and records a `BracketFilterFailure` (expression text plus a reason) in `ExposedScope.Failures`. +- A non-recursive `MembershipExact` `expose X;` entry resolves to a scope containing only `X` + itself — not its containment subtree — and, for a usage target, adds the resolved type using + the same exact-match recursion kind (itself only, not the type's subtree either). +- A recursive `MembershipRecursive` `expose X::**;` entry continues to resolve to a scope + containing `X` and its entire containment subtree, unchanged from before this fix. +- A recursive `NamespaceRecursive` `expose X::*::**;` entry resolves to a scope containing `X`'s + entire containment subtree at every depth, but excludes `X` itself — mirroring the + non-recursive `NamespaceDirectChildren` exclusion of `X`, just extended to all descendants + rather than direct children only. +- A non-recursive `NamespaceDirectChildren` `expose X::*;` entry resolves to a scope containing + only `X`'s direct (one-level) children — not `X` itself and not deeper descendants. +- A bracket-filter expression that fails to parse or evaluate always falls back to whichever + Recursive variant matches the entry's form, regardless of the entry's own recursion kind — + never degrading to the narrower Exact/DirectChildren behavior on a fallback. - (Phase 2d) A successfully-evaluated bracket-filter expression's candidate set includes named usage-level (`SysmlFeatureNode`) declarations, not just `SysmlDefinitionNode`s, so a metaclass-kind filter like `@SysML::PartUsage` can match a usage-level candidate. -- `IsInSubjectScope` returns `true` for an exact qualified-name match against a `PrefixSubjects` +- `IsInSubjectScope` returns `true` for an exact qualified-name match against a `Subjects` entry. -- `IsInSubjectScope` returns `true` for a qualified name nested under a `PrefixSubjects` entry (a +- `IsInSubjectScope` returns `true` for a qualified name nested under a `Subjects` entry (a `"{subject}::"` prefix match). - `IsInSubjectScope` returns `false` for a qualified name that shares a string prefix with a - `PrefixSubjects` entry but without the `"::"` separator (e.g. `Root::AB` vs. subject `Root::A`). + `Subjects` entry but without the `"::"` separator (e.g. `Root::AB` vs. subject `Root::A`). - `IsInSubjectScope` returns `false` for an unrelated qualified name. - (Phase 2a) `IsInSubjectScope` returns `true` for an exact qualified-name match against an `ExplicitMembers` entry, and `false` for one of that entry's own descendants — an `ExplicitMembers` match is exact-only, not a subtree match. -- `IsRootRelevantToScope` returns `true` when the candidate equals a `PrefixSubjects` entry. -- `IsRootRelevantToScope` returns `true` when the candidate is nested within a `PrefixSubjects` +- `IsRootRelevantToScope` returns `true` when the candidate equals a `Subjects` entry. +- `IsRootRelevantToScope` returns `true` when the candidate is nested within a `Subjects` entry. -- `IsRootRelevantToScope` returns `true` when a `PrefixSubjects` entry is nested within the +- `IsRootRelevantToScope` returns `true` when a `Subjects` entry is nested within the candidate. - `IsRootRelevantToScope` returns `false` for an unrelated candidate. - (Phase 2a) `IsRootRelevantToScope` returns `true` when the candidate exactly equals an @@ -72,30 +86,53 @@ configuration are required beyond a standard .NET SDK installation. - `ResolveExposedScope_NoResolvedExposeEdges_ReturnsNull`: No resolved `Expose` edges resolves to `null` scope - `ResolveExposedScope_ExposedDefinition_ReturnsThatQualifiedName`: - Exposed definition resolves to a scope whose `PrefixSubjects` is exactly that qualified name + Exposed definition resolves to a scope whose `Subjects` is exactly that qualified name - `ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget`: Exposed usage resolves to a scope including both the usage and its resolved type in - `PrefixSubjects` + `Subjects` - `ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBothPlusResolvedType`: Two `Expose` edges (a definition and a usage) union both targets plus the usage's resolved type - into `PrefixSubjects` + into `Subjects` - `ResolveExposedScope_BracketFilterEvaluatesSuccessfully_NarrowsToMatchedDefinitionsOnly`: A successfully-evaluated bracket filter narrows to only the matched descendant definitions in - `ExplicitMembers`, with an empty `PrefixSubjects` and no `Failures` for that entry + `ExplicitMembers`, with an empty `Subjects` and no `Failures` for that entry - `ResolveExposedScope_MixedFilteredAndUnfilteredEntries_NarrowsIndependently`: An unfiltered `expose` entry and a bracket-filtered `expose` entry on the same view narrow - independently — whole subtree in `PrefixSubjects` for the former, matched members in + independently — whole subtree in `Subjects` for the former, matched members in `ExplicitMembers` for the latter - `ResolveExposedScope_BracketFilterFailsToParse_FallsBackToWholeSubtreeAndRecordsFailure`: - A bracket filter that fails to parse falls back to whole-subtree inclusion in `PrefixSubjects` + A bracket filter that fails to parse falls back to whole-subtree inclusion in `Subjects` and records a `BracketFilterFailure` (expression text and reason) in `Failures` +- `ResolveExposedScope_MembershipExact_ScopeIsExactOnly`: + A non-recursive `MembershipExact` entry scopes to only the exposed subject itself, not its + containment subtree +- `ResolveExposedScope_MembershipExactUsage_TypeFallbackIsAlsoExactOnly`: + A non-recursive `MembershipExact` entry targeting a usage adds the resolved type using the same + exact-match recursion kind — the type's own descendants are not included either +- `ResolveExposedScope_MembershipRecursive_ScopeIsWholeSubtree`: + A recursive `MembershipRecursive` entry scopes to the exposed subject's entire containment + subtree — unchanged whole-subtree behavior +- `ResolveExposedScope_NamespaceDirectChildren_ScopeIsDirectChildrenOnly`: + A non-recursive `NamespaceDirectChildren` entry scopes to only the exposed subject's direct + children — not the subject itself and not deeper descendants +- `ResolveExposedScope_NamespaceRecursive_ScopeIsDescendantsOnlyExcludingSubject`: + A recursive `NamespaceRecursive` entry scopes to the exposed subject's entire containment + subtree at every depth, excluding the subject itself +- `ResolveExposedScope_NamespaceRecursiveOnDefinition_ExcludesDefinitionItselfIncludesDescendants`: + A `NamespaceRecursive` entry targeting a definition (not a bare package) excludes the + definition's own box from the resolved scope while including all descendants at any depth, + contrasted with an equivalent `MembershipRecursive` entry on the same fixture which does + include the definition itself +- `ResolveExposedScope_BracketFilterFailure_AlwaysFallsBackToRecursive`: + A bracket-filter parse failure on an entry classified as `NamespaceDirectChildren` still falls + back to whole-subtree (Recursive) inclusion, never the narrower direct-children behavior - `ResolveExposedScope_BracketFilterMetaclassKind_MatchesUsageLevelCandidate`: A successfully-evaluated bracket-filter metaclass-kind expression matches a named usage-level candidate (`SysmlFeatureNode`), not just definitions - `IsInSubjectScope_ExactMatch_ReturnsTrue`: - Exact qualified-name match against a `PrefixSubjects` entry is in scope + Exact qualified-name match against a `Subjects` entry is in scope - `IsInSubjectScope_SubtreeMatch_ReturnsTrue`: - Nested qualified name under a `PrefixSubjects` entry is in scope + Nested qualified name under a `Subjects` entry is in scope - `IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse`: String-prefix-only match (no `::`) is not in scope - `IsInSubjectScope_UnrelatedName_ReturnsFalse`: @@ -105,11 +142,11 @@ configuration are required beyond a standard .NET SDK installation. - `IsInSubjectScope_ExplicitMemberDescendant_ReturnsFalse`: A descendant of an `ExplicitMembers` entry is not automatically in scope (exact match only) - `IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue`: - Candidate equal to a `PrefixSubjects` entry is relevant + Candidate equal to a `Subjects` entry is relevant - `IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue`: - Candidate nested within a `PrefixSubjects` entry is relevant + Candidate nested within a `Subjects` entry is relevant - `IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue`: - A `PrefixSubjects` entry nested within the candidate is relevant + A `Subjects` entry nested within the candidate is relevant - `IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse`: Unrelated candidate is not relevant - `IsRootRelevantToScope_ExplicitMember_ReturnsTrue`: 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 3f19f9d..a5ec483 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 @@ -24,7 +24,14 @@ configuration are required beyond a standard .NET SDK installation. - All `GeneralViewLayoutStrategyTests` pass with zero failures across all three target frameworks. - Each user definition appears as a box carrying its definition keyword. -- A package's definitions appear inside a folder-shaped box labelled with the package name. +- When the view has no resolved `expose` scope, a bare package's definitions appear inside a + folder-shaped box labelled with the package name. +- A definition that owns nested definitions appears as exactly one box, with its nested + definitions placed inside that box (as its own `Children`) rather than as a duplicate sibling + container — regardless of whether the view is scoped or unscoped. +- When the view has a resolved `expose` scope, a bare package is never rendered as a wrapping + folder merely because it is an ancestor of admitted content; the package's admitted items are + promoted directly to the diagram's root instead. - A definition's owned usages appear as compartment rows formatted `name : Type`. - A specialization yields a line with an open end marker at the supertype end. - A `part`-feature yields a line with a filled-diamond end marker at the owner end. @@ -304,3 +311,25 @@ configuration are required beyond a standard .NET SDK installation. The OMG Safety feature-views fixture's exposed-vehicle-subtree scope now renders a non-empty scoped diagram containing the vehicle's part usages (`seatBelt`/`bumper`), while still excluding `Safety`/`Security` +- `GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxUnscoped`: + A definition owning nested definitions (`OperatorConsole` owning `DisplayPanel`/`CommsHandset`, + all inside package `Sys`, unscoped) renders exactly one `OperatorConsole` box, with + `DisplayPanel`/`CommsHandset` nested as its own children; the `Sys` folder still exists and its + own direct children are just the single `OperatorConsole` box — the Defect A regression guard +- `GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxScoped`: + The same nested-definition-owning fixture, scoped by an `expose Sys::OperatorConsole::**;` + view, still renders exactly one `OperatorConsole` box with its children correctly nested, and no + `Sys` folder appears at all — combining the Defect A and Defect B regression guards +- `GeneralViewLayoutStrategy_BuildLayout_ExposedNamespaceChildren_BarePackageAncestor_NoFolderRendered`: + A view exposing `Sys::*` (direct-children recursion) over a bare package `Sys` with plain sibling + definitions renders no `BoxShape.Folder` box anywhere, with the exposed definitions promoted + directly to the root — the Defect B regression guard, isolated from any nested-definition case +- `GeneralViewLayoutStrategy_BuildLayout_Unscoped_StillRendersFullPackageFolderStructure`: + An explicit regression guard confirming that, with no `expose`/no `ViewNode`, an ordinary + bare-package case still renders a `Folder`-shaped box with the expected package label and + expected (non-definition-container) children — unscoped behavior is provably unchanged +- `GeneralViewLayoutStrategy_BuildLayout_ExposedDefinitionInsideBarePackage_NoAncestorFolderRendered`: + Mirrors the real `BatterySubsystemView` gallery scenario directly: `part def Battery` inside + bare package `QuadcopterDrone`, exposed via `expose Battery;` (exact match) — no folder-shaped + box exists and the `Battery` box is present directly at the root level, the primary Defect B + correctness guard 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 7d2b364..9a8ed39 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -31,10 +31,17 @@ external services or additional configuration are required beyond a standard .NE 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 `ExposeMembers` (each entry pairing its qualified-name reference text with its own - bracket-filter expression text, or null when absent). 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 `ExposeMembers` (each entry pairing its qualified-name reference text, its own + bracket-filter expression text (or null when absent), and its `ExposeRecursionKind` + classification). 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). +- Each `expose` member is classified into the correct `ExposeRecursionKind` per its grammar form + and recursion setting: a bare `expose X;` as `MembershipExact`, a recursive `expose X::**;` as + `MembershipRecursive`, a bare namespace `expose X::*;` as `NamespaceDirectChildren`, and a + recursive namespace `expose X::*::**;` as `NamespaceRecursive` — fixing the prior defect where + the namespace-import branch's recursion bit was hard-coded away instead of checking for a + trailing `::**`. - `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 @@ -109,6 +116,11 @@ external services or additional configuration are required beyond a standard .NE | `VisitViewUsage` expose capture | `WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge` | | `VisitViewUsage` bracket-filter capture | `AstBuilder_ExposeBracketFilter_CapturesRawText` | | Bracket filter paired to entry | `AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath` | +| Bare MembershipExpose classification | `AstBuilder_ExposeBareMembership_CapturesMembershipExact` | +| Recursive MembershipExpose classification | `AstBuilder_ExposeRecursiveMembership_CapturesMembershipRecursive` | +| Bare NamespaceExpose classification | `AstBuilder_ExposeNamespaceDirectChildren_CapturesNamespaceDirectChildren` | +| Recursive NamespaceExpose classification | `AstBuilder_ExposeNamespaceRecursive_CapturesNamespaceRecursive` | +| Bracket-filtered classification | `WorkspaceLoader_LoadAsync_OmgSafetyFeatureViewsFixture_ResolvesBracketedExpose` | | `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 17ac2f3..534de86 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/verification/sysml2-tools-language/semantic/model/sysml-node.md @@ -36,15 +36,18 @@ external services or additional configuration are required beyond a standard .NE - `SysmlViewNode.RenderTargetName`/`FilterExpressionText` are populated verbatim from a view's `render`/`filter` body members (raw reference/expression text, never resolved into an edge or diagnostic here), and are `null` for a view with no such members. `SysmlViewNode.ExposeMembers` - is populated with one paired `ExposeMember(QualifiedName, BracketFilterExpressionText)` entry - per `expose [::**[]];` member, in source order, and is empty for a view with none; + is populated with one paired + `ExposeMember(QualifiedName, BracketFilterExpressionText, RecursionKind)` entry per + `expose [::**[]];` member, in source order, and is empty for a view with none; `GetExposedNames()`'s projected qualified names are the only data independently resolved by `ReferenceResolver` into `Expose`-kind edges. Each entry's own `BracketFilterExpressionText` is captured verbatim (raw expression text) and is paired with that same entry's `QualifiedName` — fixing the earlier Phase 1 defect where a view declaring more than one `expose` member could not - reliably associate a bracket filter with the exposed path it was declared on. `AstBuilder` - itself never evaluates a captured bracket-filter expression; real evaluation (Phase 2a) is - `ExposeScopeResolver`'s responsibility. + reliably associate a bracket filter with the exposed path it was declared on. Each entry's + `RecursionKind` is correctly classified as `MembershipExact`/`MembershipRecursive`/ + `NamespaceDirectChildren`/`NamespaceRecursive` per its grammar form and recursion setting. + `AstBuilder` itself never evaluates a captured bracket-filter expression; real evaluation + (Phase 2a) is `ExposeScopeResolver`'s responsibility. - `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 @@ -67,6 +70,10 @@ external services or additional configuration are required beyond a standard .NE | `ExposeMembers` from a `view` usage | `WorkspaceLoader_LoadAsync_ViewUsageWithExpose_RecordsExposeEdge` | | `ExposeMember` bracket-filter text paired to its path | `AstBuilder_ExposeBracketFilter_CapturesRawText` | | Bracket filter paired to entry | `AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath` | +| `RecursionKind` bare membership | `AstBuilder_ExposeBareMembership_CapturesMembershipExact` | +| `RecursionKind` recursive membership | `AstBuilder_ExposeRecursiveMembership_CapturesMembershipRecursive` | +| `RecursionKind` bare namespace | `AstBuilder_ExposeNamespaceDirectChildren_CapturesNamespaceDirectChildren` | +| `RecursionKind` recursive namespace | `AstBuilder_ExposeNamespaceRecursive_CapturesNamespaceRecursive` | | 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/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj b/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj index 6834048..ba963d1 100644 --- a/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj +++ b/src/DemaConsulting.SysML2Tools.Core/DemaConsulting.SysML2Tools.Core.csproj @@ -56,7 +56,7 @@ - + diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs index 123ef4f..1f69def 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -12,15 +12,18 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// /// The resolved qualified-name scope a view's expose statements restrict a diagram to, -/// distinguishing unfiltered "whole containment subtree" exposed paths from bracket-filtered +/// distinguishing unfiltered exposed paths — whose match kind (exact, direct-children-only, or +/// whole containment subtree) depends on each entry's SysML v2 expose grammar form and +/// recursion setting (see ) — from bracket-filtered /// (expose <path>::**[<expr>]) exposed paths that narrow to specific matched /// descendant definitions and/or named usages only. /// -/// -/// Exposed subject qualified names whose entire containment subtree is in scope (a -/// "{subject}::" prefix match) — the existing Phase 1 behavior for expose entries -/// with no bracket filter, and the fallback behavior for a bracket-filtered entry whose expression -/// failed to parse or evaluate. +/// +/// Exposed subject qualified names paired with the recursion kind that governs how they match a +/// candidate qualified name (see ) — the +/// existing Phase 1 behavior for expose entries with no bracket filter (now correctly +/// narrowed for non-recursive forms), and the fallback (always whole-subtree) behavior for a +/// bracket-filtered entry whose expression failed to parse or evaluate. /// /// /// Individual definition or named-usage qualified names matched by a successfully-evaluated @@ -28,7 +31,7 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// not automatically included unless they themselves also match the filter. /// internal sealed record ExposedScope( - IReadOnlyList PrefixSubjects, + IReadOnlyList Subjects, IReadOnlyList ExplicitMembers) { /// @@ -40,6 +43,17 @@ internal sealed record ExposedScope( public IReadOnlyList Failures { get; init; } = Array.Empty(); } +/// +/// A single resolved expose subject qualified name paired with the +/// that governs how it matches a candidate qualified name. +/// +/// The exposed subject's resolved qualified name. +/// +/// The recursion kind (exact, direct-children-only, or whole-subtree) governing how this subject +/// matches candidate qualified names — see . +/// +internal sealed record ExposeSubject(string QualifiedName, ExposeRecursionKind Recursion); + /// /// A single expose <path>::**[<expr>] bracket-filter expression that failed to /// parse or evaluate, degrading gracefully to whole-subtree inclusion for its exposed path. @@ -72,57 +86,40 @@ internal static class ExposeScopeResolver /// usage's own containment subtree is typically empty — the real content lives under its /// type's subtree. To avoid silently scoping to nothing, this also resolves the usage's own /// edge (if any) and adds that type's qualified name to the - /// scope as well, so both the usage and its type's subtree are included. This expansion only - /// applies to whole-subtree () entries — a - /// successfully-evaluated bracket filter's already - /// name the exact matched definitions or usages. + /// scope as well, using the same recursion kind for both — so both the usage and its type's + /// subtree are included, with the recursion kind (exact/direct-children/whole-subtree) + /// controlling whether the type's own descendants are also in scope. This expansion applies to + /// every entry regardless of recursion kind — it never + /// applies to , since a successfully-evaluated + /// bracket filter's matches already name the exact matched definitions or usages. /// /// The workspace, used to look up each exposed target's declaration. /// The view's AST node, or null for the synthetic --auto view. /// The resolved , or null when no scoping applies. public static ExposedScope? ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode) { - var exposedTargets = viewNode?.ResolvedEdges - .Where(edge => edge.Kind == SysmlEdgeKind.Expose) - .Select(edge => edge.TargetQualifiedName) - .ToList(); - if (exposedTargets is not { Count: > 0 }) + var resolvedExposeMembers = viewNode?.ResolvedExposeMembers; + if (resolvedExposeMembers is not { Count: > 0 }) { return null; } - var prefixSubjects = new List(); + var subjects = new List(); var explicitMembers = new List(); var failures = new List(); - // Re-pair each resolved Expose edge's target with the ExposeMember it originated from, so - // a bracket-filtered entry's expression can be evaluated against its own containment - // subtree only — not the view's other, unrelated expose entries. ReferenceResolver builds - // one edge per successfully-resolved ExposeMember in source order (skipping entries that - // failed to resolve, without emitting an edge for them), so a forward scan that skips - // non-matching entries reliably re-establishes the pairing even when an earlier entry - // failed to resolve. - var members = viewNode!.ExposeMembers; - var memberIndex = 0; - foreach (var target in exposedTargets) + // Each entry directly pairs a resolved qualified name with the specific ExposeMember it + // originated from (populated by ReferenceResolver), so a bracket-filtered entry's + // expression is always evaluated against its own containment subtree only -- not the + // view's other, unrelated expose entries. This avoids re-deriving the pairing later via + // ambiguous raw-text matching against resolved targets. + foreach (var (member, target) in resolvedExposeMembers) { - ExposeMember? member = null; - while (memberIndex < members.Count) - { - var candidate = members[memberIndex]; - memberIndex++; - if (candidate.QualifiedName == target || - target.EndsWith("::" + candidate.QualifiedName, StringComparison.Ordinal)) - { - member = candidate; - break; - } - } - - var bracketFilterText = member?.BracketFilterExpressionText; + var bracketFilterText = member.BracketFilterExpressionText; + var recursionKind = member.RecursionKind; if (bracketFilterText is not { Length: > 0 }) { - AddWholeSubtreeSubject(workspace, target, prefixSubjects); + AddSubject(workspace, target, recursionKind, subjects); continue; } @@ -130,7 +127,14 @@ internal static class ExposeScopeResolver if (parseResult.Expression is not { } expression) { failures.Add(new BracketFilterFailure(bracketFilterText, parseResult.Diagnostics.FirstOrDefault()?.Message)); - AddWholeSubtreeSubject(workspace, target, prefixSubjects); + + // Bracket-filter failures always degrade to full-subtree inclusion, never to the + // narrower Exact/DirectChildren behavior — force whichever Recursive variant + // matches the entry's form. + var fallbackKind = recursionKind is ExposeRecursionKind.NamespaceDirectChildren or ExposeRecursionKind.NamespaceRecursive + ? ExposeRecursionKind.NamespaceRecursive + : ExposeRecursionKind.MembershipRecursive; + AddSubject(workspace, target, fallbackKind, subjects); continue; } @@ -144,18 +148,24 @@ internal static class ExposeScopeResolver explicitMembers.AddRange(evaluation.MatchedQualifiedNames); } - return new ExposedScope(prefixSubjects, explicitMembers) { Failures = failures }; + return new ExposedScope(subjects, explicitMembers) { Failures = failures }; } /// /// Adds (and, when it resolves to a usage, its resolved type's - /// qualified name) to — the existing Phase 1 whole-subtree - /// inclusion behavior, shared by unfiltered expose entries and bracket-filtered entries - /// that fell back after a parse/evaluation failure. + /// qualified name — using the same for both) to + /// — the existing Phase 1 whole-subtree inclusion behavior for + /// recursive kinds, now correctly narrowed to exact/direct-children matching for non-recursive + /// kinds, shared by unfiltered expose entries and bracket-filtered entries that fell + /// back after a parse/evaluation failure. /// - private static void AddWholeSubtreeSubject(SysmlWorkspace workspace, string target, List subjects) + private static void AddSubject( + SysmlWorkspace workspace, + string target, + ExposeRecursionKind kind, + List subjects) { - subjects.Add(target); + subjects.Add(new ExposeSubject(target, kind)); if (workspace.Declarations.TryGetValue(target, out var declaration) && declaration is SysmlFeatureNode { } feature) @@ -165,24 +175,70 @@ private static void AddWholeSubtreeSubject(SysmlWorkspace workspace, string targ ?.TargetQualifiedName; if (typeTarget is not null) { - subjects.Add(typeTarget); + subjects.Add(new ExposeSubject(typeTarget, kind)); } } } /// - /// Returns when is one of - /// 's or lies within one of - /// their containment subtrees (a "{subject}::" prefix match, reusing the same - /// qualified-name-prefix idiom - /// already uses for stdlib-prefix matching), or is an exact match of one of 's + /// Returns when matches one of + /// 's per that subject's own + /// : + /// + /// : the subject + /// itself or lies within its containment subtree (a "{subject}::" prefix match, reusing + /// the same qualified-name-prefix idiom + /// already uses for + /// stdlib-prefix matching). + /// : lies within the + /// subject's containment subtree at any depth — never the subject itself. Per + /// formal-26-03-02.md §8.3.26.4, a NamespaceExpose exposes the subject's Memberships (its + /// members), and a namespace is never a member of itself, regardless of the recursive + /// flag. + /// : the subject itself + /// only, exact match. + /// : a direct + /// (one-level) child of the subject only — not the subject itself, not a deeper + /// descendant. + /// + /// or is an exact match of one of 's /// (a bracket-filter-matched definition or usage). /// public static bool IsInSubjectScope(string qualifiedName, ExposedScope scope) => - scope.PrefixSubjects.Any(subject => - qualifiedName == subject || qualifiedName.StartsWith(subject + "::", StringComparison.Ordinal)) || + scope.Subjects.Any(subject => subject.Recursion switch + { + ExposeRecursionKind.MembershipRecursive => + qualifiedName == subject.QualifiedName || + qualifiedName.StartsWith(subject.QualifiedName + "::", StringComparison.Ordinal), + ExposeRecursionKind.NamespaceRecursive => + qualifiedName.StartsWith(subject.QualifiedName + "::", StringComparison.Ordinal), + ExposeRecursionKind.MembershipExact => qualifiedName == subject.QualifiedName, + ExposeRecursionKind.NamespaceDirectChildren => IsDirectChildOf(qualifiedName, subject.QualifiedName), + _ => false, + }) || scope.ExplicitMembers.Contains(qualifiedName, StringComparer.Ordinal); + /// + /// Returns when is a direct (one-level) + /// child of — starts with "{container}::" and the + /// remainder after that prefix contains no further "::" separator. Not satisfied by + /// itself, nor by any deeper descendant. + /// + /// The candidate qualified name. + /// The container's qualified name. + /// when is a direct child of . + private static bool IsDirectChildOf(string qualifiedName, string container) + { + var prefix = container + "::"; + if (!qualifiedName.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + var remainder = qualifiedName[prefix.Length..]; + return !remainder.Contains("::", StringComparison.Ordinal); + } + /// /// Returns when (a candidate /// single-root diagram root, e.g. the definition a , @@ -216,7 +272,7 @@ bool RelevantTo(string subject) => candidateQualifiedName.StartsWith(subject + "::", StringComparison.Ordinal) || subject.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal); - return scope.PrefixSubjects.Any(RelevantTo) || scope.ExplicitMembers.Any(RelevantTo); + return scope.Subjects.Select(s => s.QualifiedName).Any(RelevantTo) || scope.ExplicitMembers.Any(RelevantTo); } /// diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 3110baa..55d538a 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -29,10 +29,13 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// intra-package edges with the bundled layered algorithm (). All /// box sizing (title bands, compartment rows) remains this strategy's responsibility, since the /// layout stage is theme-agnostic. Standard-library declarations are excluded via -/// . Depth-limited (truncated) package contents are never added to the -/// graph as individual boxes — the truncated folder becomes a single leaf node sized like a plain -/// ellipsis indicator, and the "+N more…" label is stamped onto its placed box once the layout is -/// known. +/// . caps nesting depth +/// uniformly regardless of whether the extra nesting comes from a package folder or from a +/// definition containing another definition (nested-definition containment, via +/// ): depth-limited (truncated) content is never added to the graph as +/// individual boxes — the truncated folder or definition body becomes (or gains) a single leaf +/// node sized like a plain ellipsis indicator, and the "+N more…" label is stamped onto its placed +/// box once the layout is known. /// internal sealed class GeneralViewLayoutStrategy : ILayoutStrategy { @@ -173,12 +176,25 @@ private sealed record DefBox( private readonly record struct Location(LayoutGraphNode Node, string Package); /// - /// A package folder that was depth-truncated: replaced by a leaf node sized as an ellipsis - /// indicator, decorated with its "+N more…" label once the layout places it. + /// A package folder or nested-definition container whose contents were depth-truncated: + /// replaced by a leaf ellipsis-indicator node, decorated with its "+N more…" label once the + /// layout places it. Covers both 's package-folder-contents truncation + /// (truncateFolderContents) and 's nested-definition-containment + /// truncation, so caps nesting depth uniformly regardless + /// of whether the extra nesting level comes from a package folder or from a definition + /// containing another definition. /// - /// The leaf graph node standing in for the folder. + /// The leaf graph node standing in for the truncated folder or definition body. /// Number of hidden definitions the ellipsis label reports. - private sealed record TruncatedFolder(LayoutGraphNode Node, int HiddenCount); + /// + /// Vertical offset, in logical pixels, from the placed leaf node's own top edge down to where + /// the ellipsis label should be drawn. A truncated package folder's placeholder node carries its + /// own title/keyword band (it stands in for the whole folder, not just its contents), so its + /// label sits below that reserved band. A truncated definition's ellipsis node is instead an + /// ordinary child placed inside the definition's own already-title-reserved + /// scope, so no further offset is needed (0). + /// + private sealed record TruncatedContainer(LayoutGraphNode Node, int HiddenCount, double TitleOffset); /// /// A // @@ -260,8 +276,44 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return new LayoutTree(200.0, 100.0, []); } - // Group definitions by their owning package (prefix before the last "::"). - var groups = GroupByPackage(defs); + // Partition the admitted definitions into two disjoint sets before grouping by package: + // items whose immediate parent (the qualified-name prefix before the last "::") is itself + // an admitted definition are diverted into childrenByParent, so they are nested recursively + // inside their owning definition's own box instead of being handed to GroupByPackage — which + // would otherwise (incorrectly) bucket them under their parent definition's own qualified + // name and render that parent a second time as a duplicate sibling folder (see PlaceDef). + // Everything else (a bare top-level item, or an item whose immediate parent is a bare + // package) is unaffected and flows into GroupByPackage exactly as before. + var defByQualified = defs.ToDictionary(d => d.QualifiedName, StringComparer.Ordinal); + var childrenByParent = new Dictionary>(StringComparer.Ordinal); + var packageOrRootItems = new List(); + + foreach (var def in defs) + { + var sep = def.QualifiedName.LastIndexOf("::", StringComparison.Ordinal); + var immediateParent = sep >= 0 ? def.QualifiedName[..sep] : string.Empty; + + if (immediateParent.Length > 0 && defByQualified.ContainsKey(immediateParent)) + { + if (!childrenByParent.TryGetValue(immediateParent, out var list)) + { + list = []; + childrenByParent[immediateParent] = list; + } + + list.Add(def); + } + else + { + packageOrRootItems.Add(def); + } + } + + // Group the remaining (non-nested-under-a-definition) items by their owning package + // (prefix before the last "::"). GroupByPackage itself is unchanged; it simply never again + // sees an admitted definition's own qualified name as a bucket key, which is what removes + // the duplicate-folder symptom. + var groups = GroupByPackage(packageOrRootItems); // Resolve the specialization/membership/attribute-typing edge set by qualified name; the // graph-construction pass below drops any edge touching a definition that never received a @@ -269,7 +321,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var (modelEdges, droppedEdges) = BuildModelEdges(defs, context.Workspace, scope is not null); // Build the single input graph: package folders as containers, definitions as leaves. - var (graph, truncated) = BuildGraph(groups, modelEdges, theme, options.DepthLimit); + var (graph, truncated) = BuildGraph(groups, childrenByParent, modelEdges, theme, options.DepthLimit, scope is not null); // Lay out the whole graph in one call: the root scope packs folders/top-level definitions by // reading order (containment), while each folder's own contents are ordered by their @@ -278,10 +330,12 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var rootOptions = LayoutOptions.ForAlgorithm(ContainmentLayoutAlgorithm.AlgorithmId); var tree = new HierarchicalLayoutAlgorithm().Apply(graph, rootOptions); - // Stamp the "+N more…" ellipsis label onto each truncated folder's placed box. The leaf - // algorithm emits one box per root node in Nodes order, so the boxes portion of the placed - // tree aligns with graph.Nodes by index. - var placed = truncated.Count == 0 ? tree : DecorateTruncatedFolders(tree, graph, truncated, theme); + // Stamp the "+N more…" ellipsis label onto each truncated folder's or truncated + // definition's placed box. The leaf algorithm emits one box per node in Nodes order at + // every scope (root graph and every container's own Children graph alike), so the boxes + // portion of the placed tree aligns with the corresponding graph's Nodes by index at every + // nesting depth, not just the root. + var placed = truncated.Count == 0 ? tree : DecorateTruncated(tree, graph, truncated, theme); // Surface any standalone `filter [];` evaluation failure, plus a distinct warning // for each `expose ::**[]` bracket filter that failed to parse or evaluate @@ -1035,18 +1089,41 @@ private static bool TryResolveQualified( /// Builds the single input : each package becomes a folder container /// node holding its definitions as leaves (or, when depth-truncated, a single leaf ellipsis /// placeholder); top-level (unpackaged) definitions become leaves directly on the root graph. - /// Model edges are added once every definition's node placement is known: an edge whose endpoints - /// share a non-empty package is added to that folder's own scope (an intra-package edge the - /// layered algorithm can use to order the folder's contents); every other edge — including any - /// crossing packages — is added at the root, referencing the descendant nodes directly, per the - /// lowest-common-ancestor edge convention. An edge touching a depth-truncated (unrendered) - /// definition has no node to reference and is dropped, exactly as before. + /// A definition that owns nested definitions () becomes a + /// genuine container in its own right — its nested definitions are placed inside its own node's + /// scope (recursively, to any depth) via + /// , instead of being handed to a second time + /// under the parent's qualified name, which is what used to render the parent definition twice + /// (once as an empty leaf inside its own package folder, once as a duplicate sibling folder + /// holding its children). When is (the view is + /// narrowed by a resolved expose scope), a bare package is never rendered as a wrapping + /// folder purely because it is an ancestor of admitted content: its items are promoted directly + /// onto the root graph instead, exactly like top-level (unpackaged) definitions. When + /// is , every bare package still becomes a + /// folder, unchanged from the pre-fix behavior. Model edges are added once every definition's + /// node placement is known: an edge whose endpoints share the same non-empty container scope + /// (a package folder or a definition-as-container) is added to that scope (an intra-container + /// edge the layered algorithm can use to order the container's contents); every other edge — + /// including one whose endpoints sit in different containers, even when one container is nested + /// inside the other — is added at the root, referencing the descendant nodes directly (this is + /// an exact container-scope-key match, not a genuine lowest-common-ancestor search). An edge + /// touching a depth-truncated (unrendered) definition has no node to reference and is dropped, + /// exactly as before. caps nesting depth uniformly whether the + /// extra nesting comes from a package folder or from a definition containing another + /// definition: package-folder contents and top-level/scoped-promoted items placed directly by + /// this method sit at depth 1 (the pre-existing convention — see truncateFolderContents + /// below), and every level of nested-definition containment recurses into + /// is one depth deeper again (a definition's immediate nested children sit at depth 2, its + /// grandchildren at depth 3, and so on) — see 's own remarks for how that + /// deeper truncation is applied. /// - private static (LayoutGraph Graph, List Truncated) BuildGraph( + private static (LayoutGraph Graph, List Truncated) BuildGraph( IReadOnlyList<(string Package, List Items)> groups, + IReadOnlyDictionary> childrenByParent, IReadOnlyList modelEdges, Theme theme, - int depthLimit) + int depthLimit, + bool isScoped) { var graph = new LayoutGraph(); @@ -1058,22 +1135,26 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( // explicitly to keep every distinct model relationship visible regardless of the layered // algorithm's own default. graph.Set(CoreOptions.MergeParallelEdges, false); - var truncated = new List(); + var truncated = new List(); // Reserve the full title area (package keyword + name) above a folder's contents so the // label never overlaps the first child box; the renderer draws the smaller tab notch within. var folderTitleHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); - var margin = 2.0 * theme.LabelPadding; - // Folder contents sit at depth 1; truncate them when the depth limit forbids that level. - var truncateFolderContents = depthLimit > 0 && depthLimit <= 1; + // Folder contents (and top-level or scoped-promoted items placed directly here) sit at + // depth 1 - the baseline every PlaceDef call below starts from. Nested-definition + // containment recursion goes one depth deeper per level from there. + const int rootDepth = 1; + var truncateFolderContents = depthLimit > 0 && depthLimit <= rootDepth; - // Every located definition's node and owning package, so edges can be resolved and scoped. + // Every located definition's node and owning container scope key, so edges can be resolved + // and scoped. var located = new Dictionary(StringComparer.Ordinal); - // Each non-truncated package folder's own child scope, keyed by package name, so intra-package - // edges can be added there instead of at the root. - var folderScopes = new Dictionary(StringComparer.Ordinal); + // Each non-truncated container's own child scope, keyed by its scope key (a package name, or + // an admitted definition's own qualified name when it owns nested definitions), so + // same-container edges can be added there instead of at the root. + var containerScopes = new Dictionary(StringComparer.Ordinal); for (var g = 0; g < groups.Count; g++) { @@ -1082,12 +1163,25 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( if (!isFolder) { - // Top-level (unpackaged) definitions become plain leaves directly on the root graph. + // Top-level (unpackaged) items: plain leaves directly on the root graph, recursing + // through PlaceDef so any of these items' own nested-definition children are still + // handled. foreach (var def in items) { - var leaf = MakeDefNode(graph, def); - located[def.QualifiedName] = new Location(leaf, package); - AddAnnotationNote(graph, def, leaf, theme); + PlaceDef(def, graph, package, childrenByParent, located, containerScopes, theme, rootDepth, depthLimit, truncated); + } + + continue; + } + + if (isScoped) + { + // The view is narrowed by a resolved expose scope: a bare package is never rendered + // as a wrapping folder purely because it is an ancestor of admitted content — promote + // its items directly to the root graph instead. + foreach (var def in items) + { + PlaceDef(def, graph, package, childrenByParent, located, containerScopes, theme, rootDepth, depthLimit, truncated); } continue; @@ -1098,15 +1192,12 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( // Replace the folder's definition boxes with a single leaf ellipsis indicator. It // stays a leaf (its Children graph is never touched) so the hierarchical engine keeps // this caller-computed size rather than auto-sizing it as a container. - var ellipsisWidth = Math.Max( - MinBoxWidth, - (2.0 * margin) + (items.Count.ToString(System.Globalization.CultureInfo.InvariantCulture).Length * 8.0) + 60.0); - var ellipsisHeight = (2.0 * margin) + theme.FontSizeTitle; + var (ellipsisWidth, ellipsisHeight) = ComputeEllipsisSize(items.Count, theme); var placeholder = graph.AddNode($"folder:{package}", ellipsisWidth, folderTitleHeight + ellipsisHeight); placeholder.Label = SimplePackageName(package); placeholder.Shape = BoxShape.Folder; placeholder.Keyword = "package"; - truncated.Add(new TruncatedFolder(placeholder, items.Count)); + truncated.Add(new TruncatedContainer(placeholder, items.Count, folderTitleHeight)); // None of the folder's definitions receive a node: every edge touching one is dropped. continue; @@ -1118,19 +1209,19 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( folder.Keyword = "package"; folder.TitleHeight = folderTitleHeight; folder.Set(CoreOptions.Algorithm, LayeredLayoutAlgorithm.AlgorithmId); - folderScopes[package] = folder.Children; + containerScopes[package] = folder.Children; foreach (var def in items) { - var leaf = MakeDefNode(folder.Children, def); - located[def.QualifiedName] = new Location(leaf, package); - AddAnnotationNote(folder.Children, def, leaf, theme); + PlaceDef(def, folder.Children, package, childrenByParent, located, containerScopes, theme, rootDepth, depthLimit, truncated); } } - // Add every model edge whose endpoints both received a node, scoped per the - // lowest-common-ancestor rule: same non-empty package → that folder's own scope; otherwise the - // root graph, referencing the (possibly nested) endpoint nodes directly. + // Add every model edge whose endpoints both received a node: an edge whose endpoints share + // the same non-empty container scope key is scoped to that container's own scope; every + // other edge — including one whose endpoints sit in different containers, even when one + // container is nested inside the other — is added at the root graph instead (an exact + // scope-key match, not a genuine lowest-common-ancestor search). var edgeId = 0; foreach (var edge in modelEdges) { @@ -1142,8 +1233,8 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( var scope = source.Package.Length > 0 && source.Package == target.Package && - folderScopes.TryGetValue(source.Package, out var folderScope) - ? folderScope + containerScopes.TryGetValue(source.Package, out var containerScope) + ? containerScope : graph; var added = scope.AddEdge($"e{edgeId++}", source.Node, target.Node); @@ -1155,6 +1246,95 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( return (graph, truncated); } + /// + /// Places a single definition's node into , then recursively + /// places every nested definition that owns as its immediate parent (see + /// ) inside 's own node's + /// scope — to any nesting depth, one level per recursive + /// call — instead of rendering the parent definition a second time as a duplicate sibling + /// folder. When owns nested definitions, its node's + /// is set to its own already-computed + /// (which already includes the title band and every compartment row + /// for its own owned usages), reserving exactly that band above where its nested children begin. + /// is 's own nesting depth, using the same + /// numbering already uses for package-folder contents (a top-level or + /// folder-scoped definition is depth 1); nested-definition containment is one depth deeper per + /// recursive call, so 's immediate nested children sit at + /// depth + 1. When is positive and does not permit that + /// next depth, the children are not recursed into or rendered as full boxes at all — + /// mirroring BuildGraph's truncateFolderContents package-folder-contents + /// truncation one level deeper — and a single "+N more…" ellipsis leaf (recorded in + /// ) is placed inside 's own + /// scope instead, so + /// caps nested-definition containment depth exactly as it already caps package-folder nesting. + /// A truncated definition still registers no entry (nothing + /// is ever placed inside its scope beyond the ellipsis leaf, so no intra-container edge could + /// ever resolve there); an edge touching one of the hidden children has no + /// entry to reference and is dropped, exactly like a depth-truncated package folder's contents. + /// Otherwise, its scope key is registered in so intra-container + /// edges between its own nested children resolve to its scope rather than the root. + /// + private static void PlaceDef( + DefBox def, + LayoutGraph targetScope, + string packageKeyForEdgeScoping, + IReadOnlyDictionary> childrenByParent, + Dictionary located, + Dictionary containerScopes, + Theme theme, + int depth, + int depthLimit, + List truncated) + { + var node = MakeDefNode(targetScope, def); + located[def.QualifiedName] = new Location(node, packageKeyForEdgeScoping); + AddAnnotationNote(targetScope, def, node, theme); + + if (!childrenByParent.TryGetValue(def.QualifiedName, out var children)) + { + return; + } + + node.TitleHeight = def.Height; + var childDepth = depth + 1; + + if (depthLimit > 0 && depthLimit <= childDepth) + { + // The depth limit forbids def's nested children from being rendered as individual boxes + // at all: replace them with a single leaf ellipsis indicator inside def's own Children + // scope, exactly like truncateFolderContents' package-level ellipsis in BuildGraph, one + // nesting level deeper. None of the hidden children receive a node, so every edge + // touching one is dropped. + var (ellipsisWidth, ellipsisHeight) = ComputeEllipsisSize(children.Count, theme); + var placeholder = node.Children.AddNode($"more:{def.QualifiedName}", ellipsisWidth, ellipsisHeight); + truncated.Add(new TruncatedContainer(placeholder, children.Count, TitleOffset: 0.0)); + return; + } + + containerScopes[def.QualifiedName] = node.Children; + + foreach (var child in children) + { + PlaceDef(child, node.Children, def.QualifiedName, childrenByParent, located, containerScopes, theme, childDepth, depthLimit, truncated); + } + } + + /// + /// Computes the width/height of a "+N more…" ellipsis placeholder leaf from its hidden-item + /// count, shared by 's package-folder-contents truncation and + /// 's nested-definition-containment truncation so both depth-limiting + /// paths size their placeholder identically. + /// + private static (double Width, double Height) ComputeEllipsisSize(int hiddenCount, Theme theme) + { + var margin = 2.0 * theme.LabelPadding; + var width = Math.Max( + MinBoxWidth, + (2.0 * margin) + (hiddenCount.ToString(System.Globalization.CultureInfo.InvariantCulture).Length * 8.0) + 60.0); + var height = (2.0 * margin) + theme.FontSizeTitle; + return (width, height); + } + /// Creates a definition leaf node in the given scope, carrying its keyword and compartments. private static LayoutGraphNode MakeDefNode(LayoutGraph scope, DefBox def) { @@ -1172,7 +1352,7 @@ private static LayoutGraphNode MakeDefNode(LayoutGraph scope, DefBox def) /// definition's own node was just added to (folder or root — mirrors the existing per-package /// folder scoping already used for def nodes and model edges). Implemented as an ordinary /// node/edge (rather than a post-layout coordinate-decoration pass - /// like ) so the existing + /// like ) so the existing /// / can position /// and route it automatically — a deliberate departure from the ROADMAP's suggested "adapt /// StateTransitionViewLayoutStrategy.AddInitialMarker's marker-plus-line pattern": that @@ -1242,41 +1422,82 @@ private static (double Width, double Height, IReadOnlyList Lines) Comput } /// - /// Replaces each truncated folder's placed box with one carrying its "+N more…" ellipsis label, - /// positioned within the box's now-known absolute placement. + /// Replaces each truncated folder's or truncated definition's placed box with one carrying its + /// "+N more…" ellipsis label, positioned within the box's now-known absolute placement. Recurses + /// into every placed container box's own against its matching + /// 's , so a truncated + /// definition nested at any depth — inside a folder, inside another definition, or both — is + /// found and decorated exactly like a root-level truncated folder, not just the ones directly on + /// the root graph. /// - private static LayoutTree DecorateTruncatedFolders( + private static LayoutTree DecorateTruncated( LayoutTree tree, LayoutGraph graph, - IReadOnlyList truncated, + IReadOnlyList truncated, Theme theme) { - var hiddenByNode = truncated.ToDictionary(t => t.Node, t => t.HiddenCount); - var folderTitleHeight = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + var hiddenByNode = truncated.ToDictionary(t => t.Node, t => (t.HiddenCount, t.TitleOffset)); + var nodes = DecorateTruncated(tree.Nodes, graph.Nodes, hiddenByNode, theme); + return tree with { Nodes = nodes }; + } - var nodes = new List(tree.Nodes); - for (var i = 0; i < graph.Nodes.Count && i < nodes.Count; i++) + /// + /// Recursive worker for : + /// walks one graph scope's placed nodes, matching each encountered (in + /// order) against that same scope's (in order) — the leaf + /// algorithm at any compound-graph scope, root or nested, emits exactly one box per node in + /// that scope's own Nodes order, but may also intersperse + /// entries for that scope's own routed edges among those boxes at arbitrary positions (see + /// e.g. LayeredPlacement's tree.Nodes.OfType<LayoutBox>()/ + /// OfType<LayoutLine>() filtering elsewhere in this codebase) — so the two lists + /// are matched by tracking a separate box-only cursor into + /// (advanced only when a is actually encountered), rather than by a + /// single shared index, which would fall out of sync permanently after the first interspersed line. + /// carries no Id to match by directly, so this relative-order + /// pairing is the only mechanism available. Decorates any box whose matching graph node is a + /// recorded truncation and recurses into every other container box's own children. + /// + private static List DecorateTruncated( + IReadOnlyList nodes, + IReadOnlyList graphNodes, + IReadOnlyDictionary hiddenByNode, + Theme theme) + { + var result = new List(nodes); + var graphIndex = 0; + for (var i = 0; i < result.Count && graphIndex < graphNodes.Count; i++) { - if (!hiddenByNode.TryGetValue(graph.Nodes[i], out var hiddenCount) || - nodes[i] is not LayoutBox box) + if (result[i] is not LayoutBox box) { continue; } - var indicator = new LayoutLabel( - X: box.X + theme.LabelPadding, - Y: box.Y + folderTitleHeight + theme.LabelPadding + (theme.FontSizeTitle / 2.0), - MaxWidth: box.Width - (2.0 * theme.LabelPadding), - Text: $"+{hiddenCount} more\u2026", - Align: TextAlign.Center, - Weight: FontWeight.Regular, - Style: FontStyle.Normal, - FontSize: theme.FontSizeTitle); - - nodes[i] = box with { Children = [indicator] }; + var graphNode = graphNodes[graphIndex++]; + + if (hiddenByNode.TryGetValue(graphNode, out var hidden)) + { + var indicator = new LayoutLabel( + X: box.X + theme.LabelPadding, + Y: box.Y + hidden.TitleOffset + theme.LabelPadding + (theme.FontSizeTitle / 2.0), + MaxWidth: box.Width - (2.0 * theme.LabelPadding), + Text: $"+{hidden.HiddenCount} more\u2026", + Align: TextAlign.Center, + Weight: FontWeight.Regular, + Style: FontStyle.Normal, + FontSize: theme.FontSizeTitle); + + result[i] = box with { Children = [indicator] }; + continue; + } + + if (graphNode.HasChildren && box.Children.Count > 0) + { + var decoratedChildren = DecorateTruncated(box.Children, graphNode.Children.Nodes, hiddenByNode, theme); + result[i] = box with { Children = decoratedChildren }; + } } - return tree with { Nodes = nodes }; + return result; } /// Returns the last segment of a qualified package name for use as a folder label. diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DynamicViewSynthesizer.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DynamicViewSynthesizer.cs index 1a94c4a..2891880 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DynamicViewSynthesizer.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/Internal/DynamicViewSynthesizer.cs @@ -16,15 +16,22 @@ namespace DemaConsulting.SysML2Tools.Rendering.Internal; /// /// /// The synthesized node is scoped to its target by manually populating with a single edge (plus a -/// matching entry) — the same mechanism a real, -/// parsed view def V { expose Target; } produces via ReferenceResolver. reads only these two -/// properties and has no notion of provenance, so it treats a synthesized node identically to a -/// parsed one. This differs from , whose node -/// carries no ResolvedEdges at all — that absence is what makes ExposeScopeResolver -/// return a scope (render everything); a dynamic view instead always -/// resolves to a definite, non-null scope naming exactly the requested target. +/// cref="SysmlViewNode.ResolvedExposeMembers"/> with a single entry pairing a synthesized +/// member (using +/// ) with the target's own qualified name — +/// the same shape ReferenceResolver produces for a real, parsed +/// view V { expose Target::**; } usage, so that a dynamic view shows the requested +/// target's whole containment subtree rather than the target alone. The matching +/// edge on is also +/// populated in parallel, for parity with a parsed view and for other SysmlEdgeKind.Expose +/// consumers (e.g. impact analysis, the query command's reverse-lookup index), but +/// itself reads only +/// and has no notion of provenance, so it +/// treats a synthesized node identically to a parsed one. This differs from , whose node +/// carries no ResolvedExposeMembers/ResolvedEdges at all — that absence is what +/// makes ExposeScopeResolver return a scope (render everything); a +/// dynamic view instead always resolves to a definite, non-null scope rooted at the requested +/// target's whole subtree. /// /// /// The synthesized view's uses a leading $ — a @@ -130,13 +137,15 @@ internal static (SysmlViewNode? ViewNode, string? Diagnostic) Synthesize( var viewName = "$" + (target.Name ?? targetQualifiedName); + var syntheticExposeMember = new ExposeMember(targetQualifiedName, null, ExposeRecursionKind.MembershipRecursive); var viewNode = new SysmlViewNode { Name = viewName, QualifiedName = viewQualifiedName, RenderTargetName = renderTargetName, - ExposeMembers = [new ExposeMember(targetQualifiedName, null)], + ExposeMembers = [syntheticExposeMember], ResolvedEdges = [new SysmlEdge(viewQualifiedName, targetQualifiedName, SysmlEdgeKind.Expose)], + ResolvedExposeMembers = [(syntheticExposeMember, targetQualifiedName)], FilterExpressionText = filterExpressionText, }; diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index 1ad293a..86d5fa6 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -2028,12 +2028,23 @@ private static IReadOnlyList ExtractExposedNames( continue; } - var (qn, _, bracketFilterText) = ExtractImportTarget( + var isNamespaceForm = expose.namespaceExpose() is not null; + var (qn, _, bracketFilterText, isRecursive) = ExtractImportTarget( expose.namespaceExpose()?.namespaceImport(), expose.membershipExpose()?.membershipImport()); if (qn is { Length: > 0 }) { - members.Add(new ExposeMember(qn, bracketFilterText)); + var hasBracketFilter = bracketFilterText is not null; + var recursionKind = (isNamespaceForm, isRecursive, hasBracketFilter) switch + { + (true, _, true) => ExposeRecursionKind.NamespaceRecursive, + (false, _, true) => ExposeRecursionKind.MembershipRecursive, + (true, true, false) => ExposeRecursionKind.NamespaceRecursive, + (true, false, false) => ExposeRecursionKind.NamespaceDirectChildren, + (false, true, false) => ExposeRecursionKind.MembershipRecursive, + (false, false, false) => ExposeRecursionKind.MembershipExact, + }; + members.Add(new ExposeMember(qn, bracketFilterText, recursionKind)); } } @@ -2070,7 +2081,7 @@ private static IReadOnlyList ExtractExposedNames( return null; } - var (qn, isWildcard, bracketFilterText) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); + var (qn, isWildcard, bracketFilterText, _) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); if (qn is null) { return null; @@ -2099,9 +2110,12 @@ private static IReadOnlyList ExtractExposedNames( /// The membership-import alternative, or null when not this form. /// /// The extracted qualified/dotted name text (or null when neither alternative yielded - /// text) and whether the import/expose is a wildcard. + /// text), whether the import/expose is a wildcard, the bracket-filter expression text (if + /// any), and whether this entry is recursive (derived from a trailing ::**, or + /// always true for bracket-filtered forms by design, since the grammar only reaches that + /// alternative via ::**[filterExpr]). /// - private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExpressionText) ExtractImportTarget( + private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExpressionText, bool IsRecursive) ExtractImportTarget( SysMLv2Parser.NamespaceImportContext? namespaceImport, SysMLv2Parser.MembershipImportContext? membershipImport) { @@ -2111,7 +2125,7 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp var qn = namespaceImport.qualifiedName()?.GetText(); if (qn is { Length: > 0 }) { - return (qn, true, null); + return (qn, true, null, namespaceImport.STAR_STAR() is not null); } // Bracketed-filter form: qualifiedName::**[filterExpr] — the dominant expose form in @@ -2123,6 +2137,10 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp // filterPackageMember (multiple bracket filters chained on one path are extremely // rare; the first is representative); it is paired with its ExposeMember by // ExtractExposedNames and evaluated per-entry by ExposeScopeResolver (Phase 2a). + // Bracket-filtered forms are always treated as recursive (IsRecursive: true) + // regardless of the nested optional STAR_STAR token — the grammar's filterPackage + // form is treated as always recursive by design (the grammar only reaches this + // alternative via ::**[filterExpr]). var filterPackage = namespaceImport.filterPackage(); var bracketFilterText = filterPackage?.filterPackageMember()?.FirstOrDefault()?.ownedExpression() is { } bracketExpr ? GetOriginalText(bracketExpr) @@ -2136,7 +2154,7 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp var filterQn = filterMembershipImport.qualifiedName()?.GetText(); if (filterQn is { Length: > 0 }) { - return (filterQn, filterMembershipImport.STAR_STAR() is not null, bracketFilterText); + return (filterQn, filterMembershipImport.STAR_STAR() is not null, bracketFilterText, true); } } @@ -2146,7 +2164,7 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp var directQn = namespaceImportDirect.qualifiedName()?.GetText(); if (directQn is { Length: > 0 }) { - return (directQn, true, bracketFilterText); + return (directQn, true, bracketFilterText, true); } } } @@ -2159,11 +2177,12 @@ private static (string? QualifiedName, bool IsWildcard, string? BracketFilterExp var qn = membershipImport.qualifiedName()?.GetText(); if (qn is { Length: > 0 }) { - return (qn, membershipImport.STAR_STAR() is not null, null); + var isRecursive = membershipImport.STAR_STAR() is not null; + return (qn, isRecursive, null, isRecursive); } } - return (null, false, null); + return (null, false, null, false); } /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs index 8e96c8d..bce7a87 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs @@ -795,30 +795,39 @@ private void ResolveNode( } } - // Views resolve only GetExposedNames() (a view's expose members) into Expose edges; each - // entry is resolved independently, and an unresolved entry produces the Warning diagnostic - // below with no edge. RenderTargetName (a view's render member) names a rendering style or - // format, not a content-scoping subject -- per the SysML v2 grammar it never refers to - // model content, so ReferenceResolver never inspects it: no edge is produced and no - // diagnostic is emitted for it, mirroring how FilterExpressionText (raw source text, not a - // reference) is also intentionally never touched here. + // Views resolve each ExposeMembers entry independently into an Expose edge (unresolved + // entries produce the Warning diagnostic below with no edge) -- iterating ExposeMembers + // directly (rather than the flattened GetExposedNames() projection) lets each resolved + // qualified name be paired unambiguously with the specific ExposeMember object it came + // from, via ResolvedExposeMembers, instead of leaving callers to re-derive that pairing + // later from raw-text matching (which is ambiguous whenever an earlier entry fails to + // resolve but its raw text is a suffix of a later entry's resolved target). RenderTargetName + // (a view's render member) names a rendering style or format, not a content-scoping subject + // -- per the SysML v2 grammar it never refers to model content, so ReferenceResolver never + // inspects it: no edge is produced and no diagnostic is emitted for it, mirroring how + // FilterExpressionText (raw source text, not a reference) is also intentionally never + // touched here. if (node is SysmlViewNode view) { - foreach (var exposedName in view.GetExposedNames()) + var resolvedExposeMembers = new List<(ExposeMember Member, string ResolvedQualifiedName)>(); + foreach (var member in view.ExposeMembers) { - if (TryResolve(exposedName, namespaceStack, imports, out var resolvedExposed)) + if (TryResolve(member.QualifiedName, namespaceStack, imports, out var resolvedExposed)) { nodeEdges.Add(new SysmlEdge(node.QualifiedName, resolvedExposed, SysmlEdgeKind.Expose)); + resolvedExposeMembers.Add((member, resolvedExposed)); } - else if (resolvedInFile.Add(exposedName)) + else if (resolvedInFile.Add(member.QualifiedName)) { _diagnostics.Add(new SysmlDiagnostic( filePath, 0, 0, DiagnosticSeverity.Warning, - $"Unresolved reference: '{exposedName}'")); + $"Unresolved reference: '{member.QualifiedName}'")); } } + + view.ResolvedExposeMembers = resolvedExposeMembers; } // Metadata annotations (SysmlMetadataNode, a Children entry of the element it annotates) diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs index 2cbbb0d..2c57458 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs @@ -320,6 +320,26 @@ public sealed class SysmlViewNode : SysmlNode /// The qualified name of each expose member, in source order. public IReadOnlyList GetExposedNames() => ExposeMembers.Select(m => m.QualifiedName).ToList(); + /// + /// Gets each paired directly with its own resolved qualified + /// name, populated by for every ExposeMembers entry + /// that successfully resolves (in the same relative order, skipping entries that fail to + /// resolve without inserting a placeholder). Unlike re-deriving this pairing from + /// 's edges by + /// forward-scanning and loose-matching raw reference text against resolved qualified names + /// (ambiguous when an earlier entry fails to resolve but its raw text happens to be a + /// suffix of a later entry's resolved target), this list unambiguously identifies which + /// specific object produced each resolved qualified name, so + /// ExposeScopeResolver can read each resolved target's own + /// / + /// correctly. Empty when the view has no expose members or none resolved. The + /// edges on are + /// still populated in parallel and retain their existing role (e.g. impact analysis, the + /// query command's reverse-lookup index). + /// + public IReadOnlyList<(ExposeMember Member, string ResolvedQualifiedName)> ResolvedExposeMembers { get; set; } = + Array.Empty<(ExposeMember Member, string ResolvedQualifiedName)>(); + /// /// Gets the raw source text of this view's filter [<expression>]; statement /// (from elementFilterMember().ownedExpression().GetText()), or @@ -356,7 +376,62 @@ public sealed class SysmlViewNode : SysmlNode /// scope to the matching descendant definitions only, falling back to whole-subtree inclusion /// (plus a diagnostic) when the expression fails to parse or evaluate. /// -public sealed record ExposeMember(string QualifiedName, string? BracketFilterExpressionText); +/// +/// Classifies which SysML v2 expose grammar form and recursion setting produced this +/// entry (). Consumed by ExposeScopeResolver to decide +/// between an exact/direct-children match and a whole-subtree match when resolving this +/// entry's contribution to a view's exposed scope. Defaults to +/// — the pre-existing whole-subtree +/// scoping behavior — so external code constructing an via the +/// previous two-argument form continues to compile and behave exactly as before this +/// parameter was added. +/// +public sealed record ExposeMember( + string QualifiedName, + string? BracketFilterExpressionText, + ExposeRecursionKind RecursionKind = ExposeRecursionKind.MembershipRecursive); + +/// +/// Classifies which SysML v2 expose grammar form and recursion setting produced an +/// entry, per formal-26-03-02.md §8.3.26.2-4: MembershipExpose +/// (expose X; / expose X::**;) and NamespaceExpose (expose X::*; / +/// expose X::*::**;) each carry their own independent isRecursive flag derived +/// from a trailing ::**. A bracket-filtered entry (expose X::**[expr]) is always +/// one of the two *Recursive kinds — the grammar's filterPackage form is treated as always +/// recursive by design, since that alternative is only reachable via ::**[filterExpr] +/// (regardless of the nested optional STAR_STAR token) — because +/// ExposeScopeResolver only consults this classification for its unfiltered/fallback +/// whole-subtree-vs-narrow behavior, never for a successfully-evaluated bracket filter's own +/// (already-exact) ExplicitMembers. +/// +public enum ExposeRecursionKind +{ + /// + /// MembershipExpose, non-recursive: expose X; — only X itself (plus, for a usage + /// target, its resolved type itself — see ExposeScopeResolver) is in scope. + /// + MembershipExact, + + /// + /// MembershipExpose, recursive: expose X::**; — X and its entire containment + /// subtree (the pre-fix, still-correct-for-this-case whole-subtree behavior). + /// + MembershipRecursive, + + /// + /// NamespaceExpose, non-recursive: expose X::*; — only X's direct (one-level) + /// children, not X itself and not deeper descendants. + /// + NamespaceDirectChildren, + + /// + /// NamespaceExpose, recursive: expose X::*::**; — X's descendants at any depth + /// (not just direct children), but never X itself: per formal-26-03-02.md §8.3.26.4, a + /// NamespaceExpose exposes the subject's own Memberships (its members), and a namespace is + /// never a member of itself regardless of the recursive flag. + /// + NamespaceRecursive, +} /// /// AST node representing a viewpoint definition. diff --git a/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj b/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj index ed8f6b0..01d00bd 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj +++ b/src/DemaConsulting.SysML2Tools.Tool/DemaConsulting.SysML2Tools.Tool.csproj @@ -98,8 +98,8 @@ - - + + diff --git a/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj b/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj index 9ccc32a..ebf7cf2 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj +++ b/test/DemaConsulting.SysML2Tools.Tests/DemaConsulting.SysML2Tools.Tests.csproj @@ -56,15 +56,15 @@ - - + + - - - + + + diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs index fda2cba..e34e8a8 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -457,9 +457,9 @@ public void ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("ProcessB", null)], + ExposeMembers = [new ExposeMember("ProcessB", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessB", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -485,9 +485,9 @@ public void ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_Select { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("b1", null)], + ExposeMembers = [new ExposeMember("b1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessB::b1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -559,9 +559,9 @@ public void ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_Selec { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("c1", null)], + ExposeMembers = [new ExposeMember("c1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessA::ProcessC::c1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -585,9 +585,9 @@ public void ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("Unrelated", null)], + ExposeMembers = [new ExposeMember("Unrelated", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -613,9 +613,9 @@ public void ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction( { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("a1", null)], + ExposeMembers = [new ExposeMember("a1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessA::a1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -654,9 +654,9 @@ public void ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot( { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("myProcess", null)], + ExposeMembers = [new ExposeMember("myProcess", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::myProcess", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs index 5b00fff..8c52f9a 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs @@ -142,9 +142,9 @@ public void GridView_BuildLayout_ExposedName_UnionsAdditionalSubtree() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("A", null)], + ExposeMembers = [new ExposeMember("A", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -195,9 +195,9 @@ public void GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionS { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("myVehicle", null)], + ExposeMembers = [new ExposeMember("myVehicle", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -223,13 +223,13 @@ public void GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("A", null), new ExposeMember("B", null)], + ExposeMembers = [new ExposeMember("A", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("B", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), new SysmlEdge("Root::V", "Root::B", SysmlEdgeKind.Expose) ] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -267,9 +267,9 @@ public void GridView_BuildLayout_ExposeOneSideOfSpecialization_KeepsBothRowAndCo { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Sub", null)], + ExposeMembers = [new ExposeMember("Sub", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::A::Sub", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -301,9 +301,9 @@ public void BrowserView_BuildLayout_ExposedName_UnionsAdditionalSubtree() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("A", null)], + ExposeMembers = [new ExposeMember("A", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -353,9 +353,9 @@ public void BrowserView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefiniti { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("myVehicle", null)], + ExposeMembers = [new ExposeMember("myVehicle", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -380,13 +380,13 @@ public void BrowserView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("A", null), new ExposeMember("B", null)], + ExposeMembers = [new ExposeMember("A", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("B", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), new SysmlEdge("Root::V", "Root::B", SysmlEdgeKind.Expose) ] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs index ba3626d..27dc537 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs @@ -16,6 +16,17 @@ namespace DemaConsulting.SysML2Tools.Tests.Layout; /// public sealed class ExposeScopeResolverTests { + /// + /// Builds the pairing for test fixtures + /// where each 's raw is + /// already the fully-resolved qualified name (i.e. every member resolves to itself, in + /// order) — the common case for every test in this file except the dedicated re-pairing + /// regression test, which builds its own mismatched pairing explicitly. + /// + private static IReadOnlyList<(ExposeMember Member, string ResolvedQualifiedName)> ResolvedMembers( + params ExposeMember[] members) => + members.Select(m => (m, m.QualifiedName)).ToList(); + /// /// A null ViewNode (the synthetic --auto view) resolves to null scope, /// meaning "no scoping applies" — every non-stdlib element is included. @@ -52,7 +63,7 @@ public void ResolveExposedScope_NoResolvedExposeEdges_ReturnsNull() /// /// A resolved Expose edge targeting a definition, with no bracket filter, resolves to - /// a scope whose contains exactly that + /// a scope whose contains exactly that /// definition's qualified name. /// [Fact] @@ -69,14 +80,15 @@ public void ResolveExposedScope_ExposedDefinition_ReturnsThatQualifiedName() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::A", null)], + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Equal(["Root::A"], scope.PrefixSubjects); + Assert.Equal(["Root::A"], scope.Subjects.Select(s => s.QualifiedName)); Assert.Empty(scope.ExplicitMembers); Assert.Empty(scope.Failures); } @@ -107,29 +119,30 @@ public void ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget() { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::myVehicle", null)], + ExposeMembers = [new ExposeMember("Root::myVehicle", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Contains("Root::myVehicle", scope.PrefixSubjects); - Assert.Contains("Root::Vehicle", scope.PrefixSubjects); + Assert.Contains("Root::myVehicle", scope.Subjects.Select(s => s.QualifiedName)); + Assert.Contains("Root::Vehicle", scope.Subjects.Select(s => s.QualifiedName)); } /// An exact qualified-name match is in scope. [Fact] public void IsInSubjectScope_ExactMatch_ReturnsTrue() { - Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A", new ExposedScope(["Root::A"], []))); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// A qualified name nested under a subject's containment subtree is in scope. [Fact] public void IsInSubjectScope_SubtreeMatch_ReturnsTrue() { - Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", new ExposedScope(["Root::A"], []))); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// @@ -140,14 +153,14 @@ public void IsInSubjectScope_SubtreeMatch_ReturnsTrue() [Fact] public void IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse() { - Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::AB", new ExposedScope(["Root::A"], []))); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::AB", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// An unrelated qualified name is not in scope. [Fact] public void IsInSubjectScope_UnrelatedName_ReturnsFalse() { - Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::B", new ExposedScope(["Root::A"], []))); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::B", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// An explicit (bracket-filter-matched) member is in scope by exact match only. @@ -171,7 +184,7 @@ public void IsInSubjectScope_ExplicitMemberDescendant_ReturnsFalse() [Fact] public void IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue() { - Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", new ExposedScope(["Root::A"], []))); + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// @@ -181,7 +194,7 @@ public void IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue() [Fact] public void IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue() { - Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A::Child", new ExposedScope(["Root::A"], []))); + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A::Child", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// @@ -192,14 +205,14 @@ public void IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue() [Fact] public void IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue() { - Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", new ExposedScope(["Root::A::Child"], []))); + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", new ExposedScope([new ExposeSubject("Root::A::Child", ExposeRecursionKind.MembershipRecursive)], []))); } /// A candidate root unrelated to any exposed subject is not relevant to the scope. [Fact] public void IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse() { - Assert.False(ExposeScopeResolver.IsRootRelevantToScope("Root::B", new ExposedScope(["Root::A"], []))); + Assert.False(ExposeScopeResolver.IsRootRelevantToScope("Root::B", new ExposedScope([new ExposeSubject("Root::A", ExposeRecursionKind.MembershipRecursive)], []))); } /// An explicit (bracket-filter-matched) member is itself relevant to the scope. @@ -306,20 +319,21 @@ public void ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBo { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::A", null), new ExposeMember("Root::myVehicle", null)], + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("Root::myVehicle", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose) ] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Contains("Root::A", scope.PrefixSubjects); - Assert.Contains("Root::myVehicle", scope.PrefixSubjects); - Assert.Contains("Root::Vehicle", scope.PrefixSubjects); + Assert.Contains("Root::A", scope.Subjects.Select(s => s.QualifiedName)); + Assert.Contains("Root::myVehicle", scope.Subjects.Select(s => s.QualifiedName)); + Assert.Contains("Root::Vehicle", scope.Subjects.Select(s => s.QualifiedName)); } /// @@ -344,9 +358,10 @@ public void ResolveExposedScope_BracketFilterMetaclassKind_MatchesUsageLevelCand { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::Container", "@SysML::PartUsage")], + ExposeMembers = [new ExposeMember("Root::Container", "@SysML::PartUsage", ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::Container", SysmlEdgeKind.Expose)] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); @@ -361,7 +376,7 @@ public void ResolveExposedScope_BracketFilterMetaclassKind_MatchesUsageLevelCand /// under the target's containment subtree that carry the @Safety metadata /// annotation — the target itself and its unannotated sibling are excluded from /// , and the target is not added to - /// (no whole-subtree fallback). + /// (no whole-subtree fallback). /// [Fact] public void ResolveExposedScope_BracketFilterEvaluatesSuccessfully_NarrowsToMatchedDefinitionsOnly() @@ -390,14 +405,15 @@ public void ResolveExposedScope_BracketFilterEvaluatesSuccessfully_NarrowsToMatc { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::Container", "@Safety")], + ExposeMembers = [new ExposeMember("Root::Container", "@Safety", ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::Container", SysmlEdgeKind.Expose)] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Empty(scope.PrefixSubjects); + Assert.Empty(scope.Subjects); Assert.Equal(["Root::Container::Matched"], scope.ExplicitMembers); Assert.Empty(scope.Failures); } @@ -405,7 +421,7 @@ public void ResolveExposedScope_BracketFilterEvaluatesSuccessfully_NarrowsToMatc /// /// Two expose entries on the same view, only one bracket-filtered, each narrow /// independently: the unfiltered entry keeps its whole containment subtree - /// (), while the bracket-filtered entry narrows to + /// (), while the bracket-filtered entry narrows to /// only its own matched descendant definitions () — /// proving a bracket filter on one entry does not affect an unrelated entry's contribution. /// @@ -439,8 +455,8 @@ public void ResolveExposedScope_MixedFilteredAndUnfilteredEntries_NarrowsIndepen QualifiedName = "Root::V", ExposeMembers = [ - new ExposeMember("Root::Plain", null), - new ExposeMember("Root::Container", "@Safety") + new ExposeMember("Root::Plain", null, ExposeRecursionKind.MembershipRecursive), + new ExposeMember("Root::Container", "@Safety", ExposeRecursionKind.MembershipRecursive) ], ResolvedEdges = [ @@ -448,11 +464,12 @@ public void ResolveExposedScope_MixedFilteredAndUnfilteredEntries_NarrowsIndepen new SysmlEdge("Root::V", "Root::Container", SysmlEdgeKind.Expose) ] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Equal(["Root::Plain"], scope.PrefixSubjects); + Assert.Equal(["Root::Plain"], scope.Subjects.Select(s => s.QualifiedName)); Assert.Equal(["Root::Container::Matched"], scope.ExplicitMembers); Assert.Empty(scope.Failures); } @@ -460,7 +477,7 @@ public void ResolveExposedScope_MixedFilteredAndUnfilteredEntries_NarrowsIndepen /// /// A bracket-filter expression that fails to parse (an unsupported Phase 1 construct) /// degrades gracefully to whole-subtree inclusion for that entry (via - /// ) and records the failure (with a reason) in + /// ) and records the failure (with a reason) in /// , rather than crashing or silently excluding the /// entry. /// @@ -478,18 +495,336 @@ public void ResolveExposedScope_BracketFilterFailsToParse_FallsBackToWholeSubtre { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("Root::Container", "x istype Y")], + ExposeMembers = [new ExposeMember("Root::Container", "x istype Y", ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::Container", SysmlEdgeKind.Expose)] }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); Assert.NotNull(scope); - Assert.Equal(["Root::Container"], scope.PrefixSubjects); + Assert.Equal(["Root::Container"], scope.Subjects.Select(s => s.QualifiedName)); Assert.Empty(scope.ExplicitMembers); var failure = Assert.Single(scope.Failures); Assert.Equal("x istype Y", failure.ExpressionText); Assert.NotNull(failure.Reason); } + + /// + /// A non-recursive MembershipExact expose entry (expose X;) resolves to a + /// scope containing only the exposed subject itself — its containment subtree is not + /// included. + /// + [Fact] + public void ResolveExposedScope_MembershipExact_ScopeIsExactOnly() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.MembershipExact)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A", scope)); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", scope)); + } + + /// + /// A non-recursive MembershipExact expose entry targeting a usage still adds the + /// usage's resolved type, using the same exact-match recursion kind — the type's own + /// descendants are not included either. + /// + [Fact] + public void ResolveExposedScope_MembershipExactUsage_TypeFallbackIsAlsoExactOnly() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "Root::Vehicle", DefinitionKeyword = "part def" }, + ["Root::Vehicle::Engine"] = new SysmlDefinitionNode { Name = "Engine", QualifiedName = "Root::Vehicle::Engine", DefinitionKeyword = "part def" }, + ["Root::myVehicle"] = new SysmlFeatureNode + { + Name = "myVehicle", + QualifiedName = "Root::myVehicle", + FeatureTyping = "Vehicle", + ResolvedEdges = [new SysmlEdge("Root::myVehicle", "Root::Vehicle", SysmlEdgeKind.Typing)] + } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::myVehicle", null, ExposeRecursionKind.MembershipExact)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::myVehicle", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::Vehicle", scope)); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::Vehicle::Engine", scope)); + } + + /// + /// A recursive MembershipRecursive expose entry (expose X::**;) resolves to a + /// scope containing the exposed subject's entire containment subtree — unchanged whole + /// subtree behavior. + /// + [Fact] + public void ResolveExposedScope_MembershipRecursive_ScopeIsWholeSubtree() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.MembershipRecursive)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", scope)); + } + + /// + /// A non-recursive NamespaceDirectChildren expose entry (expose X::*;) + /// resolves to a scope containing only X's direct children — not X itself and + /// not deeper (grandchild) descendants. + /// + [Fact] + public void ResolveExposedScope_NamespaceDirectChildren_ScopeIsDirectChildrenOnly() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" }, + ["Root::A::Child::Grandchild"] = new SysmlDefinitionNode { Name = "Grandchild", QualifiedName = "Root::A::Child::Grandchild", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::A", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", scope)); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::A::Child::Grandchild", scope)); + } + + /// + /// A recursive NamespaceRecursive expose entry (expose X::*::**;) resolves to + /// a scope containing X's descendants at any depth — but never X itself, per + /// formal-26-03-02.md §8.3.26.4 (a NamespaceExpose exposes the subject's own Memberships, + /// and a namespace is never a member of itself regardless of the recursive flag). + /// + [Fact] + public void ResolveExposedScope_NamespaceRecursive_ScopeIsDescendantsOnlyExcludingSubject() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" }, + ["Root::A::Child::Grandchild"] = new SysmlDefinitionNode { Name = "Grandchild", QualifiedName = "Root::A::Child::Grandchild", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::A", null, ExposeRecursionKind.NamespaceRecursive)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::A", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child::Grandchild", scope)); + } + + /// + /// A bracket-filtered expose entry that fails to parse falls back to whole-subtree + /// inclusion regardless of the entry's own recursion kind — even a non-recursive + /// MembershipExact-classified bracket-filter entry (a hypothetical malformed + /// grammar edge case) degrades to recursive fallback, per the documented + /// "don't silently lose content" fallback intent. + /// + [Fact] + public void ResolveExposedScope_BracketFilterFailure_AlwaysFallsBackToRecursive() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [new ExposeMember("Root::A", "x istype Y", ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + viewNode.ResolvedExposeMembers = ResolvedMembers(viewNode.ExposeMembers.ToArray()); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::A", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", scope)); + Assert.Single(scope.Failures); + } + + /// + /// Regression test for the reviewer-flagged re-pairing ambiguity on PR #36: a view with + /// expose A; where A does not resolve (a typo/nonexistent name, so no + /// entry is produced for it) followed by + /// expose X::A; which does resolve, where X::A happens to share its + /// simple name with the unresolved first entry. The two entries also have different + /// s (MembershipExact vs. NamespaceRecursive) + /// so a wrong pairing would be observably different in the resolved scope. Because + /// now populates + /// directly (one entry per successfully-resolved member, correctly skipping the unresolved + /// first entry without leaving a placeholder), the resolved scope correctly reflects + /// X::A's own semantics — not the + /// unresolved first entry's . + /// + [Fact] + public void ResolveExposedScope_EarlierUnresolvedEntrySuffixOfLaterResolvedTarget_PairsCorrectly() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::X"] = new SysmlDefinitionNode { Name = "X", QualifiedName = "Root::X", DefinitionKeyword = "part def" }, + ["Root::X::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::X::A", DefinitionKeyword = "part def" }, + ["Root::X::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::X::A::Child", DefinitionKeyword = "part def" } + } + }; + + // "expose A;" never resolves (A does not exist), so ReferenceResolver produces no + // ResolvedExposeMembers entry for it -- only the "expose X::A::**;" entry resolves. + var unresolvedMember = new ExposeMember("A", null, ExposeRecursionKind.MembershipExact); + var resolvedMember = new ExposeMember("X::A", null, ExposeRecursionKind.NamespaceRecursive); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposeMembers = [unresolvedMember, resolvedMember], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::X::A", SysmlEdgeKind.Expose)], + ResolvedExposeMembers = [(resolvedMember, "Root::X::A")] + }; + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + + // If the resolved edge were wrongly paired with the unresolved "A" (MembershipExact) entry, + // "Root::X::A" itself would be in scope but its descendant "Root::X::A::Child" would not. + // The correct pairing (NamespaceRecursive) excludes "Root::X::A" itself but includes its + // descendant at any depth. + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::X::A", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::X::A::Child", scope)); + } + + /// + /// Regression test for Bug 2: a NamespaceRecursive expose entry + /// (expose SomeDefinition::*::**;) targeting a + /// directly (not a bare package) excludes the definition's own box from the resolved scope, + /// while all of its descendants at any depth (not just direct children) are included. + /// Contrasted with an equivalent MembershipRecursive entry + /// (expose SomeDefinition::**;) on the same fixture, which does include the + /// definition itself. + /// + [Fact] + public void ResolveExposedScope_NamespaceRecursiveOnDefinition_ExcludesDefinitionItselfIncludesDescendants() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::SomeDefinition"] = new SysmlDefinitionNode { Name = "SomeDefinition", QualifiedName = "Root::SomeDefinition", DefinitionKeyword = "part def" }, + ["Root::SomeDefinition::Nested"] = new SysmlDefinitionNode { Name = "Nested", QualifiedName = "Root::SomeDefinition::Nested", DefinitionKeyword = "part def" }, + ["Root::SomeDefinition::Nested::Deep"] = new SysmlDefinitionNode { Name = "Deep", QualifiedName = "Root::SomeDefinition::Nested::Deep", DefinitionKeyword = "part def" } + } + }; + + var namespaceRecursiveMember = new ExposeMember("Root::SomeDefinition", null, ExposeRecursionKind.NamespaceRecursive); + var namespaceViewNode = new SysmlViewNode + { + Name = "V1", + QualifiedName = "Root::V1", + ExposeMembers = [namespaceRecursiveMember], + ResolvedEdges = [new SysmlEdge("Root::V1", "Root::SomeDefinition", SysmlEdgeKind.Expose)] + }; + namespaceViewNode.ResolvedExposeMembers = ResolvedMembers(namespaceViewNode.ExposeMembers.ToArray()); + + var namespaceScope = ExposeScopeResolver.ResolveExposedScope(workspace, namespaceViewNode); + + Assert.NotNull(namespaceScope); + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition", namespaceScope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition::Nested", namespaceScope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition::Nested::Deep", namespaceScope)); + + var membershipRecursiveMember = new ExposeMember("Root::SomeDefinition", null, ExposeRecursionKind.MembershipRecursive); + var membershipViewNode = new SysmlViewNode + { + Name = "V2", + QualifiedName = "Root::V2", + ExposeMembers = [membershipRecursiveMember], + ResolvedEdges = [new SysmlEdge("Root::V2", "Root::SomeDefinition", SysmlEdgeKind.Expose)] + }; + membershipViewNode.ResolvedExposeMembers = ResolvedMembers(membershipViewNode.ExposeMembers.ToArray()); + + var membershipScope = ExposeScopeResolver.ResolveExposedScope(workspace, membershipViewNode); + + Assert.NotNull(membershipScope); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition", membershipScope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition::Nested", membershipScope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::SomeDefinition::Nested::Deep", membershipScope)); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index efddb9f..3c2bd02 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using System.Reflection; using DemaConsulting.Rendering; using DemaConsulting.Rendering.Abstractions; using DemaConsulting.SysML2Tools.Layout.Internal; @@ -167,6 +168,237 @@ public void GeneralViewLayoutStrategy_BuildLayout_PackagedDefinitions_ProducesFo Assert.Equal("Sys", folder.Label); } + /// + /// Builds a workspace where Sys::OperatorConsole owns two nested definitions, + /// Sys::OperatorConsole::DisplayPanel and Sys::OperatorConsole::CommsHandset, + /// all inside package Sys — the fixture reproducing the real mission-control + /// gallery model's duplicate-box defect (Defect A). + /// + private static SysmlWorkspace BuildNestedDefinitionWorkspace() => new() + { + Declarations = new Dictionary + { + ["Sys::OperatorConsole"] = new SysmlDefinitionNode + { + Name = "OperatorConsole", + QualifiedName = "Sys::OperatorConsole", + DefinitionKeyword = "part def" + }, + ["Sys::OperatorConsole::DisplayPanel"] = new SysmlDefinitionNode + { + Name = "DisplayPanel", + QualifiedName = "Sys::OperatorConsole::DisplayPanel", + DefinitionKeyword = "part def" + }, + ["Sys::OperatorConsole::CommsHandset"] = new SysmlDefinitionNode + { + Name = "CommsHandset", + QualifiedName = "Sys::OperatorConsole::CommsHandset", + DefinitionKeyword = "part def" + } + } + }; + + /// + /// Unscoped: a definition that owns nested definitions renders exactly one box for + /// itself (regression guard against the sibling-duplicate-folder defect) with its nested + /// definitions placed as children of that single box, nested inside the still-present + /// package folder — not as a duplicate sibling folder. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxUnscoped() + { + // Arrange: OperatorConsole owns DisplayPanel and CommsHandset, all inside package Sys, unscoped + var strategy = new GeneralViewLayoutStrategy(); + var workspace = BuildNestedDefinitionWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one OperatorConsole box exists + var allBoxes = CollectBoxes(layout.Nodes); + var consoleBoxes = allBoxes.Where(b => b.Label == "OperatorConsole").ToList(); + Assert.Single(consoleBoxes); + + // Assert: the single OperatorConsole box's own children include DisplayPanel and CommsHandset + var consoleChildLabels = CollectBoxes(consoleBoxes[0].Children).Select(b => b.Label).ToList(); + Assert.Contains("DisplayPanel", consoleChildLabels); + Assert.Contains("CommsHandset", consoleChildLabels); + + // Assert: the Sys package folder still exists (unscoped preserves package folders), and its + // own direct children include the single OperatorConsole box, not DisplayPanel/CommsHandset + // directly. + var folder = allBoxes.First(b => b.Shape == BoxShape.Folder && b.Label == "Sys"); + var folderChildLabels = folder.Children.OfType().Select(b => b.Label).ToList(); + Assert.Contains("OperatorConsole", folderChildLabels); + Assert.DoesNotContain("DisplayPanel", folderChildLabels); + Assert.DoesNotContain("CommsHandset", folderChildLabels); + } + + /// + /// Scoped: the same nested-definition-owning fixture, but with a view exposing + /// Sys::OperatorConsole::** (whole-subtree recursion). Still renders exactly one + /// OperatorConsole box (no duplicate), its children still nested correctly, and — + /// combined with Defect B's bare-package folder suppression — no Sys folder appears + /// at all (its only admitted content's immediate parent, OperatorConsole, is itself + /// an admitted definition, so OperatorConsole is promoted directly to root). + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_DefinitionOwningNestedDefinitions_RendersOneContainerBoxScoped() + { + // Arrange: same workspace as the unscoped test, but exposing Sys::OperatorConsole::** + var strategy = new GeneralViewLayoutStrategy(); + var workspace = BuildNestedDefinitionWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Sys::V", + ExposeMembers = [new ExposeMember("OperatorConsole", null, ExposeRecursionKind.MembershipRecursive)], + ResolvedEdges = [new SysmlEdge("Sys::V", "Sys::OperatorConsole", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one OperatorConsole box exists, with DisplayPanel/CommsHandset nested + // as its own children. + var allBoxes = CollectBoxes(layout.Nodes); + var consoleBoxes = allBoxes.Where(b => b.Label == "OperatorConsole").ToList(); + Assert.Single(consoleBoxes); + var consoleChildLabels = CollectBoxes(consoleBoxes[0].Children).Select(b => b.Label).ToList(); + Assert.Contains("DisplayPanel", consoleChildLabels); + Assert.Contains("CommsHandset", consoleChildLabels); + + // Assert: no Sys folder appears — OperatorConsole is promoted directly to root instead of + // being wrapped in a folder for its bare-package ancestor. + Assert.DoesNotContain(allBoxes, b => b.Shape == BoxShape.Folder); + } + + /// + /// Scoped, isolating Defect B alone (no nested-definition-owning case involved): a view + /// exposing Sys::* (direct-children recursion) over a bare package Sys + /// containing plain sibling definitions renders no box + /// anywhere, while the exposed definitions' boxes are present directly at the root. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_ExposedNamespaceChildren_BarePackageAncestor_NoFolderRendered() + { + // Arrange: bare package Sys with two sibling definitions, exposing Sys::* (direct children) + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Sys::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Sys::A", DefinitionKeyword = "part def" }, + ["Sys::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Sys::B", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Sys::V", + ExposeMembers = [new ExposeMember("Sys", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("Sys::V", "Sys", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no folder-shaped box exists anywhere in the result + var allBoxes = CollectBoxes(layout.Nodes); + Assert.DoesNotContain(allBoxes, b => b.Shape == BoxShape.Folder); + + // Assert: the exposed definitions' boxes are present directly under the root node list + var rootLabels = CollectBoxes(layout.Nodes).Select(b => b.Label).ToList(); + Assert.Contains("A", rootLabels); + Assert.Contains("B", rootLabels); + } + + /// + /// Explicit regression guard: with no expose/no , an + /// ordinary bare-package case still renders a box with the + /// expected package label and expected non-definition-container children, confirming + /// unscoped behavior is provably unchanged. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Unscoped_StillRendersFullPackageFolderStructure() + { + // Arrange: two sibling definitions inside package Sys, unscoped + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Sys::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Sys::A", DefinitionKeyword = "part def" }, + ["Sys::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Sys::B", DefinitionKeyword = "part def" } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a Sys folder box still exists, with A and B nested directly as its own children + var allBoxes = CollectBoxes(layout.Nodes); + var folder = allBoxes.First(b => b.Shape == BoxShape.Folder); + Assert.Equal("package", folder.Keyword); + Assert.Equal("Sys", folder.Label); + var folderChildLabels = CollectBoxes(folder.Children).Select(b => b.Label).ToList(); + Assert.Contains("A", folderChildLabels); + Assert.Contains("B", folderChildLabels); + } + + /// + /// Mirrors the real BatterySubsystemView gallery scenario directly: a single + /// part def Battery inside bare package QuadcopterDrone, exposed via + /// expose Battery; (exact match, single-target expose). No + /// box exists in the result, and the Battery box is present directly at the root + /// level — the primary Defect B correctness guard. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_ExposedDefinitionInsideBarePackage_NoAncestorFolderRendered() + { + // Arrange: Battery inside bare package QuadcopterDrone, exposing Battery exactly + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["QuadcopterDrone::Battery"] = new SysmlDefinitionNode + { + Name = "Battery", + QualifiedName = "QuadcopterDrone::Battery", + DefinitionKeyword = "part def" + } + } + }; + var viewNode = new SysmlViewNode + { + Name = "BatterySubsystemView", + QualifiedName = "QuadcopterDrone::BatterySubsystemView", + ExposeMembers = [new ExposeMember("Battery", null, ExposeRecursionKind.MembershipExact)], + ResolvedEdges = [new SysmlEdge("QuadcopterDrone::BatterySubsystemView", "QuadcopterDrone::Battery", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no folder-shaped box exists, and Battery is present directly at the root level + var allBoxes = CollectBoxes(layout.Nodes); + Assert.DoesNotContain(allBoxes, b => b.Shape == BoxShape.Folder); + var rootLabels = layout.Nodes.OfType().Select(b => b.Label).ToList(); + Assert.Contains("Battery", rootLabels); + } + /// /// BuildLayout draws a specialization edge (a ) between a subtype /// and its supertype when both are present in the workspace. @@ -1061,9 +1293,9 @@ public void GeneralViewLayoutStrategy_BuildLayout_ExposedName_UnionsAdditionalSu { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("A", null)], + ExposeMembers = [new ExposeMember("A", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1128,9 +1360,9 @@ public void GeneralViewLayoutStrategy_BuildLayout_ExposedUsage_ResolvesThroughTy { Name = "V", QualifiedName = "Root::V", - ExposeMembers = [new ExposeMember("myVehicle", null)], + ExposeMembers = [new ExposeMember("myVehicle", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -2259,5 +2491,206 @@ public void GeneralViewLayoutStrategy_BuildLayout_EnumDefLiteralValues_ProducesE var box = CollectBoxes(layout.Nodes).First(b => b.Label == "FlightMode"); Assert.Contains(box.Compartments, c => c.Title == "enum values" && c.Rows.Contains("manual") && c.Rows.Contains("auto")); } + + /// + /// Builds a workspace where package Sys contains Sys::Outer, which owns one + /// nested definition Sys::Outer::Inner — a 2-level-deep nested-definition-containment + /// structure (folder contents at depth 1, Outer's own nested child at depth 2), used + /// by the nested-definition-containment truncation + /// regression tests. + /// + private static SysmlWorkspace BuildTwoLevelNestedDefinitionWorkspace() => new() + { + Declarations = new Dictionary + { + ["Sys::Outer"] = new SysmlDefinitionNode + { + Name = "Outer", + QualifiedName = "Sys::Outer", + DefinitionKeyword = "part def" + }, + ["Sys::Outer::Inner"] = new SysmlDefinitionNode + { + Name = "Inner", + QualifiedName = "Sys::Outer::Inner", + DefinitionKeyword = "part def" + } + } + }; + + /// + /// Regression test for the reviewer-confirmed bug: previously + /// only capped package-folder-contents nesting (truncateFolderContents), never + /// nested-definition containment introduced by PlaceDef's own recursion — so a + /// DepthLimit that should cap all nesting had no effect once a definition owned a + /// nested definition. With the fix, a DepthLimit of 2 over the 2-level-deep + /// Outer/Inner nested-definition-containment fixture truncates the innermost + /// level (Inner, Outer's own nested child, at depth 2): Outer's own box + /// is still rendered normally, but in place of an Inner box its own + /// carries a single "+1 more…" ellipsis + /// , mirroring how a depth-truncated package folder's contents are + /// already reported. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_NestedDefinitionContainment_DepthLimitTruncatesInnerLevel() + { + // Arrange: Sys::Outer owns Sys::Outer::Inner, DepthLimit = 2 + var strategy = new GeneralViewLayoutStrategy(); + var workspace = BuildTwoLevelNestedDefinitionWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light, DepthLimit: 2); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: Outer's own box still renders, but Inner does not appear as a box anywhere + var allBoxes = CollectBoxes(layout.Nodes); + var outerBox = allBoxes.Single(b => b.Label == "Outer"); + Assert.DoesNotContain(allBoxes, b => b.Label == "Inner"); + + // Assert: Outer's own Children carries a single placeholder box standing in for the + // truncated Inner definition, itself decorated with a "+1 more…" ellipsis label — mirroring + // how a depth-truncated package folder's own placed box is decorated, one nesting level + // deeper. + var placeholder = Assert.IsType(Assert.Single(outerBox.Children)); + var indicator = Assert.IsType(Assert.Single(placeholder.Children)); + Assert.Equal("+1 more\u2026", indicator.Text); + } + + /// + /// Regression guard: == 0 (unlimited) still renders + /// full nested-definition-containment depth unchanged — the fix must not truncate anything + /// when no depth limit is requested. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_NestedDefinitionContainment_DepthLimitZero_RendersFullDepth() + { + // Arrange: same Outer/Inner fixture, but with the default unlimited DepthLimit + var strategy = new GeneralViewLayoutStrategy(); + var workspace = BuildTwoLevelNestedDefinitionWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: both Outer and Inner render as full boxes, Inner nested as Outer's own child, and + // no ellipsis placeholder label appears anywhere. + var allBoxes = CollectBoxes(layout.Nodes); + var outerBox = allBoxes.Single(b => b.Label == "Outer"); + var innerBox = Assert.IsType(Assert.Single(outerBox.Children)); + Assert.Equal("Inner", innerBox.Label); + } + + /// + /// Regression guard: unrelated to nested-definition containment, a + /// of 1 still truncates a plain package folder's own contents exactly as before the fix + /// (truncateFolderContents in BuildGraph), proving the nested-definition-containment + /// fix did not regress the pre-existing package-folder-contents truncation path. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_PackageFolderContents_DepthLimitOne_StillTruncatesFolder() + { + // Arrange: package Sys with two sibling definitions (no definition-containment nesting), + // DepthLimit = 1 + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Sys::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Sys::A", DefinitionKeyword = "part def" }, + ["Sys::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Sys::B", DefinitionKeyword = "part def" } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light, DepthLimit: 1); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the folder is present but neither A nor B renders as an individual box + var allBoxes = CollectBoxes(layout.Nodes); + var folder = allBoxes.Single(b => b.Shape == BoxShape.Folder); + Assert.Equal("Sys", folder.Label); + Assert.DoesNotContain(allBoxes, b => b.Label is "A" or "B"); + + // Assert: the folder carries a "+2 more…" ellipsis label reporting its two hidden definitions + var indicator = Assert.IsType(Assert.Single(folder.Children)); + Assert.Equal("+2 more\u2026", indicator.Text); + } + + /// + /// Regression test (reflection-based, since the recursive worker is private) for the + /// reviewer-confirmed pairing bug in GeneralViewLayoutStrategy's recursive + /// DecorateTruncated worker: it previously matched each placed + /// against a scope's list using a single shared loop index, but + /// a scope's placed node list can intersperse entries (routed + /// intra-scope edges) among its boxes at arbitrary positions — every + /// encountered before a box permanently shifted the box-to-graph-node pairing by one, so the + /// wrong (and therefore the wrong truncation/hidden-count + /// decoration) got matched to each subsequent box. This test builds a minimal fixture with + /// one placed before two entries, only the + /// second of which corresponds to a truncated , and invokes the + /// private recursive worker directly via reflection — there is no public seam to reach it, and + /// reproducing this box/line ordering deterministically through the public + /// BuildLayout/DemaConsulting.Rendering layout-algorithm pipeline is not + /// practical since box/line placement ordering is an internal detail of that external + /// package. With the fix, the leading line no longer perturbs the pairing: the first box + /// (matching the non-truncated node) is left unchanged, and only the second box (matching the + /// truncated node) is decorated with the ellipsis label. + /// + [Fact] + public void GeneralViewLayoutStrategy_DecorateTruncated_LineBeforeBoxes_DoesNotBreakBoxToNodePairing() + { + // Arrange: two graph nodes at this scope — nodeA (kept) and nodeB (truncated, hiding 3 items) + var graph = new LayoutGraph(); + var nodeA = graph.AddNode("A", 100, 60); + var nodeB = graph.AddNode("B", 100, 60); + + // A placed line (an intra-scope routed edge) appears before either box in the placed-node list + var line = new LayoutLine( + Waypoints: [], + SourceEnd: EndMarkerStyle.None, + TargetEnd: EndMarkerStyle.None, + LineStyle: LineStyle.Solid, + MidpointLabel: null); + + var boxA = new LayoutBox( + X: 0, Y: 0, Width: 100, Height: 60, Label: "A", Depth: 1, Shape: BoxShape.Rectangle, + Compartments: [], Children: [], Keyword: null, RoundedCornerRadius: null, + FolderTabWidth: null, FolderTabHeight: null, ContentInsetLeft: 0, ContentInsetRight: 0, + ContentInsetTop: 0, ContentInsetBottom: 0, CenterTitle: false); + var boxB = boxA with { Label = "B" }; + + var placed = new List { line, boxA, boxB }; + var graphNodes = new List { nodeA, nodeB }; + var hiddenByNode = new Dictionary + { + [nodeB] = (3, 0.0) + }; + + var method = typeof(GeneralViewLayoutStrategy) + .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(m => m.Name == "DecorateTruncated" && m.GetParameters().Length == 4 + && m.GetParameters()[0].ParameterType == typeof(IReadOnlyList)); + + // Act + var result = (List)method.Invoke( + null, [placed, graphNodes, hiddenByNode, Themes.Light])!; + + // Assert: the leading line is preserved untouched + Assert.Same(line, result[0]); + + // Assert: boxA (paired with the non-truncated nodeA) is unchanged — not decorated + var resultBoxA = Assert.IsType(result[1]); + Assert.Equal("A", resultBoxA.Label); + Assert.Empty(resultBoxA.Children); + + // Assert: boxB (paired with the truncated nodeB) carries the "+3 more…" ellipsis decoration + var resultBoxB = Assert.IsType(result[2]); + Assert.Equal("B", resultBoxB.Label); + var indicatorB = Assert.IsType(Assert.Single(resultBoxB.Children)); + Assert.Equal("+3 more\u2026", indicatorB.Text); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index 314e4d1..fbf6a48 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -760,9 +760,9 @@ public void InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_Se { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("c1", null)], + ExposeMembers = [new ExposeMember("c1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::SysA::SysC::c1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -953,9 +953,9 @@ public void InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExpose { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("SysB", null)], + ExposeMembers = [new ExposeMember("SysB", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::SysB", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1025,13 +1025,13 @@ public void InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBre { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("AB", null), new ExposeMember("MuchLongerSiblingName", null)], + ExposeMembers = [new ExposeMember("AB", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("MuchLongerSiblingName", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("M::V", "M::AB", SysmlEdgeKind.Expose), new SysmlEdge("M::V", "M::MuchLongerSiblingName", SysmlEdgeKind.Expose) ] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1055,9 +1055,9 @@ public void InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_S { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("b1", null)], + ExposeMembers = [new ExposeMember("b1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::SysB::b1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1086,9 +1086,9 @@ public void InterconnectionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSele { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("Unrelated", null)], + ExposeMembers = [new ExposeMember("Unrelated", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1111,9 +1111,9 @@ public void InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart() { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("a1", null)], + ExposeMembers = [new ExposeMember("a1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::SysA::a1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1140,13 +1140,13 @@ public void InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtre { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("a1", null), new ExposeMember("a2", null)], + ExposeMembers = [new ExposeMember("a1", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("a2", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("M::V", "M::SysA::a1", SysmlEdgeKind.Expose), new SysmlEdge("M::V", "M::SysA::a2", SysmlEdgeKind.Expose) ] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -1179,9 +1179,9 @@ public void InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingTo { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("myPart", null)], + ExposeMembers = [new ExposeMember("myPart", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::myPart", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs index 2fd0490..1a8849c 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs @@ -269,9 +269,9 @@ public void SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot() { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("ProtocolB", null)], + ExposeMembers = [new ExposeMember("ProtocolB", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolB", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -298,9 +298,9 @@ public void SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsI { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("x", null)], + ExposeMembers = [new ExposeMember("x", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolB::x", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -372,9 +372,9 @@ public void SequenceView_BuildLayout_ExposeInnerLifelineOfNestedDefinition_Selec { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("x", null)], + ExposeMembers = [new ExposeMember("x", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolA::ProtocolC::x", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -399,9 +399,9 @@ public void SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_Re { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("Unrelated", null)], + ExposeMembers = [new ExposeMember("Unrelated", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -428,9 +428,9 @@ public void SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines() { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("server", null)], + ExposeMembers = [new ExposeMember("server", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolA::server", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -466,13 +466,13 @@ public void SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMess { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("client", null), new ExposeMember("server", null)], + ExposeMembers = [new ExposeMember("client", null, ExposeRecursionKind.MembershipRecursive), new ExposeMember("server", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [ new SysmlEdge("M::V", "M::ProtocolA::client", SysmlEdgeKind.Expose), new SysmlEdge("M::V", "M::ProtocolA::server", SysmlEdgeKind.Expose) ] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -506,9 +506,9 @@ public void SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot() { Name = "V", QualifiedName = "M::V", - ExposeMembers = [new ExposeMember("myProtocol", null)], + ExposeMembers = [new ExposeMember("myProtocol", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("M::V", "M::myProtocol", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs index 3b74a3b..1465df2 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -313,9 +313,9 @@ public void StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExpose { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("MachineB", null)], + ExposeMembers = [new ExposeMember("MachineB", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineB", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -341,9 +341,9 @@ public void StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_S { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("b1", null)], + ExposeMembers = [new ExposeMember("b1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineB::b1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -414,9 +414,9 @@ public void StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_S { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("b1", null)], + ExposeMembers = [new ExposeMember("b1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineA::MachineB::b1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -440,9 +440,9 @@ public void StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSele { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("Unrelated", null)], + ExposeMembers = [new ExposeMember("Unrelated", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::Unrelated", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -468,9 +468,9 @@ public void StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOf { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("s1", null)], + ExposeMembers = [new ExposeMember("s1", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineA::s1", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); @@ -508,9 +508,9 @@ public void StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingTo { Name = "V", QualifiedName = "SM::V", - ExposeMembers = [new ExposeMember("myMachine", null)], + ExposeMembers = [new ExposeMember("myMachine", null, ExposeRecursionKind.MembershipRecursive)], ResolvedEdges = [new SysmlEdge("SM::V", "SM::myMachine", SysmlEdgeKind.Expose)] - }; + }.WithResolvedExposeMembers(); var context = new ViewContext("v", workspace, viewNode); var options = new RenderOptions(Themes.Light); diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/TestSysmlViewNodeExtensions.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/TestSysmlViewNodeExtensions.cs new file mode 100644 index 0000000..aafbddf --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/TestSysmlViewNodeExtensions.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Test-only helper reconstructing for +/// hand-built test fixtures across this directory that manually set +/// and +/// directly (bypassing ReferenceResolver, which normally populates +/// itself). Pairs each +/// with its corresponding resolved +/// edge target by position, valid only for the simple 1:1 fixtures used throughout these +/// layout-strategy tests (every member resolves, in source order, with no gaps) — the dedicated +/// re-pairing regression test in ExposeScopeResolverTests builds its own mismatched +/// pairing explicitly instead of using this helper. +/// +internal static class TestSysmlViewNodeExtensions +{ + /// + /// Populates 's by + /// pairing its (in order) with its resolved + /// edge targets (in order), and returns the same instance + /// for fluent chaining in object-initializer-style test setup. + /// + /// The view node to populate. + /// The same instance, for chaining. + /// + /// Thrown when the number of does not exactly + /// match the number of resolved edges — this helper's + /// documented 1:1 contract is violated, and silently pairing a mismatched or truncated + /// count via Zip could mask a broken test fixture behind an incorrect pairing rather + /// than failing fast. + /// + public static SysmlViewNode WithResolvedExposeMembers(this SysmlViewNode view) + { + var targets = view.ResolvedEdges + .Where(edge => edge.Kind == SysmlEdgeKind.Expose) + .Select(edge => edge.TargetQualifiedName) + .ToList(); + + if (targets.Count != view.ExposeMembers.Count) + { + throw new InvalidOperationException( + $"WithResolvedExposeMembers requires a 1:1 pairing: {view.ExposeMembers.Count} " + + $"ExposeMembers but {targets.Count} resolved Expose edges. Use a mismatched " + + "fixture built by hand (see ExposeScopeResolverTests) instead of this helper if " + + "that is the scenario under test."); + } + + view.ResolvedExposeMembers = view.ExposeMembers + .Zip(targets, (member, target) => (Member: member, ResolvedQualifiedName: target)) + .ToList(); + + return view; + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DynamicViewSynthesizerTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DynamicViewSynthesizerTests.cs index 9454653..71aa83e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/DynamicViewSynthesizerTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/DynamicViewSynthesizerTests.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.SysML2Tools.Layout.Internal; using DemaConsulting.SysML2Tools.Rendering; using DemaConsulting.SysML2Tools.Semantic; using DemaConsulting.SysML2Tools.Semantic.Model; @@ -33,9 +34,54 @@ public void Synthesize_GeneralKind_ResolvableTarget_Succeeds() Assert.Equal("asGeneralDiagram", viewNode!.RenderTargetName); Assert.Single(viewNode.ExposeMembers); Assert.Equal("P::Widget", viewNode.ExposeMembers[0].QualifiedName); + Assert.Equal(ExposeRecursionKind.MembershipRecursive, viewNode.ExposeMembers[0].RecursionKind); var edge = Assert.Single(viewNode.ResolvedEdges); Assert.Equal(SysmlEdgeKind.Expose, edge.Kind); Assert.Equal("P::Widget", edge.TargetQualifiedName); + + // ExposeScopeResolver.ResolveExposedScope reads ResolvedExposeMembers (not + // ResolvedEdges/ExposeMembers directly) to resolve a view's scope — this must be + // populated too, or a dynamic view silently renders unscoped (the full workspace) + // instead of narrowed to the requested target. + var resolvedMember = Assert.Single(viewNode.ResolvedExposeMembers); + Assert.Equal("P::Widget", resolvedMember.Member.QualifiedName); + Assert.Equal(ExposeRecursionKind.MembershipRecursive, resolvedMember.Member.RecursionKind); + Assert.Equal("P::Widget", resolvedMember.ResolvedQualifiedName); + } + + /// + /// Regression test: a synthesized dynamic view must actually narrow the rendered scope to + /// the requested target's subtree via , + /// not silently fall back to rendering the entire unscoped workspace. This guards against a + /// previous regression where DynamicViewSynthesizer populated the (now-superseded) + /// / pair but + /// not , which + /// actually reads. + /// + [Fact] + public void Synthesize_ResolveExposedScope_NarrowsToTargetSubtreeOnly() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Widget"] = new SysmlDefinitionNode { Name = "Widget", QualifiedName = "P::Widget", DefinitionKeyword = "part def" }, + ["P::Widget::Nested"] = new SysmlDefinitionNode { Name = "Nested", QualifiedName = "P::Widget::Nested", DefinitionKeyword = "part def" }, + ["P::Unrelated"] = new SysmlDefinitionNode { Name = "Unrelated", QualifiedName = "P::Unrelated", DefinitionKeyword = "part def" } + } + }; + + var viewNode = DiagramRenderer.SynthesizeDynamicView(workspace, "general", "P::Widget", null, out var diagnostic); + + Assert.Null(diagnostic); + Assert.NotNull(viewNode); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode!); + + Assert.NotNull(scope); + Assert.True(ExposeScopeResolver.IsInSubjectScope("P::Widget", scope)); + Assert.True(ExposeScopeResolver.IsInSubjectScope("P::Widget::Nested", scope)); + Assert.False(ExposeScopeResolver.IsInSubjectScope("P::Unrelated", scope)); } /// A "grid" dynamic view targeting any resolvable non-stdlib definition succeeds. diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs index 3045470..2a3fe8b 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/AstBuilderMetadataTests.cs @@ -22,7 +22,7 @@ public sealed class AstBuilderMetadataTests [Fact] public async Task AstBuilder_BareMetadataAnnotation_CapturesMetadataNode() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -62,7 +62,7 @@ part def Engine { [Fact] public async Task AstBuilder_MetadataAnnotationWithBooleanAttribute_CapturesLiteralValue() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -107,7 +107,7 @@ part def Engine { [Fact] public async Task AstBuilder_MetadataAnnotation_ResolvesTypeReference() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -149,7 +149,7 @@ part def Engine { [Fact] public async Task AstBuilder_MetadataAnnotation_UnresolvedType_ProducesWarning() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -186,7 +186,7 @@ part def Engine { [Fact] public async Task AstBuilder_ExposeBracketFilter_CapturesRawText() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -217,6 +217,7 @@ view V { var member = Assert.Single(viewNode.ExposeMembers); Assert.Equal("P", member.QualifiedName); Assert.Equal("@Safety", member.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.NamespaceRecursive, member.RecursionKind); } finally { @@ -234,7 +235,7 @@ view V { [Fact] public async Task AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath() { - var tempFile = Path.GetTempFileName() + ".sysml"; + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); try { await File.WriteAllTextAsync( @@ -270,10 +271,12 @@ view V { var chassisMember = viewNode.ExposeMembers[0]; Assert.Equal("Chassis", chassisMember.QualifiedName); Assert.Null(chassisMember.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.MembershipExact, chassisMember.RecursionKind); var engineMember = viewNode.ExposeMembers[1]; Assert.Equal("Engine", engineMember.QualifiedName); Assert.Equal("@Safety", engineMember.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.NamespaceRecursive, engineMember.RecursionKind); // GetExposedNames() remains the flat qualified-name projection consumed by // ReferenceResolver, unaffected by which entries carry a bracket filter. @@ -284,4 +287,165 @@ view V { File.Delete(tempFile); } } + + /// + /// A bare expose X; (MembershipExpose, non-recursive) captures + /// — only X itself is in scope, + /// not its containment subtree. + /// + [Fact] + public async Task AstBuilder_ExposeBareMembership_CapturesMembershipExact() + { + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + part def Engine; + + view V { + expose Engine; + } + } + """, + 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 member = Assert.Single(viewNode.ExposeMembers); + Assert.Equal("Engine", member.QualifiedName); + Assert.Null(member.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.MembershipExact, member.RecursionKind); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A recursive expose X::**; (MembershipExpose, recursive) captures + /// X and its entire + /// containment subtree are in scope. + /// + [Fact] + public async Task AstBuilder_ExposeRecursiveMembership_CapturesMembershipRecursive() + { + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + part def Engine; + + view V { + expose Engine::**; + } + } + """, + 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 member = Assert.Single(viewNode.ExposeMembers); + Assert.Equal("Engine", member.QualifiedName); + Assert.Null(member.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.MembershipRecursive, member.RecursionKind); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A non-recursive namespace expose X::*; (NamespaceExpose, non-recursive) captures + /// — only X's direct + /// children are in scope, not X itself and not deeper descendants. + /// + [Fact] + public async Task AstBuilder_ExposeNamespaceDirectChildren_CapturesNamespaceDirectChildren() + { + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + view V { + expose P::*; + } + } + """, + 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 member = Assert.Single(viewNode.ExposeMembers); + Assert.Equal("P", member.QualifiedName); + Assert.Null(member.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.NamespaceDirectChildren, member.RecursionKind); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A recursive namespace expose X::*::**; (NamespaceExpose, recursive) captures + /// X's entire containment + /// subtree is in scope, excluding X itself. + /// + [Fact] + public async Task AstBuilder_ExposeNamespaceRecursive_CapturesNamespaceRecursive() + { + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync( + tempFile, + """ + package P { + view V { + expose P::*::**; + } + } + """, + 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 member = Assert.Single(viewNode.ExposeMembers); + Assert.Equal("P", member.QualifiedName); + Assert.Null(member.BracketFilterExpressionText); + Assert.Equal(ExposeRecursionKind.NamespaceRecursive, member.RecursionKind); + } + finally + { + File.Delete(tempFile); + } + } } + diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index edb44c7..cd87186 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -3869,6 +3869,7 @@ public async Task WorkspaceLoader_LoadAsync_OmgSafetyFeatureViewsFixture_Resolve e.TargetQualifiedName == "'11b-Safety and Security Feaure Views'::PartsTree::vehicle"); var member = Assert.Single(view.ExposeMembers); Assert.Equal("@Safety and (as Safety).isMandatory", member.BracketFilterExpressionText); + Assert.Equal(DemaConsulting.SysML2Tools.Semantic.Model.ExposeRecursionKind.NamespaceRecursive, member.RecursionKind); } ///