diff --git a/.cspell.yaml b/.cspell.yaml index 02144f93..8a0ac109 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -53,6 +53,8 @@ words: - Skia - Subsetting - subsettings + - subsetted + - Subsetted - Sugiyama - Tagawa - Toda diff --git a/ROADMAP.md b/ROADMAP.md index 00b9bd31..d7d24be3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,11 +20,22 @@ The work falls into three themes: Render the relationships currently omitted from the General View, each routed via `ChannelRouter` and carrying the correct spec end shape: -- Subsetting (where shown as edges), feature typing, dependency, containment, - connection/binding, allocation. +- Subsetting (where shown as edges), dependency, connection/binding, allocation. +- Fix `ref` usages, currently drawn as a hollow-diamond Membership edge: SysML v2 removed + "shared aggregation" (the UML/SysML v1 hollow-diamond concept), so `ref` should render as a + dependency-style edge (dashed, open arrowhead) instead. - Shared-bus generalization (multiple subtypes merging into one line to a supertype) as an optional readability refinement. +Per the OMG SysML v2 spec's Graphical Notation chapter (§8.2.3), `item`, `occurrence`, +`action`, `state`, and `requirement` usages are **not** edge-connected boxes — they are +canonically rendered as compartment rows on their owning box (same mechanism as +`attribute`), which `GeneralViewLayoutStrategy.BuildCompartments` already does generically +for any feature keyword. No new edge kind or "containment broadening" is needed for these; +earlier roadmap phrasing implying a `containment` edge kind was based on secondary-source +notation tables that conflated other relationships (item flow, action succession, +requirement satisfy/derive) with containment, and has been corrected here. + **Scope:** `AstBuilder`/semantic exposure of the relationship kinds as needed; `GeneralViewLayoutStrategy` edge emission; resolver coverage. Also extend the drone gallery model (`docs/gallery/models/01-drone-general.sysml`, which today has no real `connect`/`bind`, @@ -90,6 +101,28 @@ primitives (bar, diamond, pentagon, note). `LayoutActivation`/`LayoutBand` alrea **Visual gate:** sequence shows activation bars + a fragment; action flow shows a fork/join and a decision/merge with correct shapes. +### State Transition View: implied-source (initial-pseudostate) transitions + +SysML v2 allows a state transition with no explicit source state (`then TargetState;`), +meaning an implicit/default transition taken automatically on entry to the enclosing region — +the UML/SysML "initial pseudostate" concept. Today `ReferenceResolver`'s `Transition` edge is +only recorded when both a source and target resolve, so an omitted source produces no edge at +all (a documented limitation, not a crash) — confirmed against the real OMG corpus fixture +`training/12.BindingConnectors/...` cross-checks during the General View relationship-edges +work. + +Closing this requires more than an edge-resolution tweak: `AstBuilder`/`ReferenceResolver` need +to distinguish "no source specified" from "source failed to resolve," and +`StateTransitionViewLayoutStrategy` needs to render the conventional small filled-circle +initial-pseudostate marker with an edge into the target state, rather than only ever connecting +two named states. + +**Scope:** `AstBuilder` (transition parsing), `SysmlEdge`/`ReferenceResolver` (distinguishing +implied-source from unresolved-source), `StateTransitionViewLayoutStrategy` (initial-pseudostate +marker + edge rendering). +**Visual gate:** a state machine with a `then InitialState;`-shaped entry transition renders a +filled-circle initial marker with an edge into that state. + ### Interconnection View: genuine cross-boundary connector routing `InterconnectionViewLayoutStrategy` now resolves a connection endpoint's full dotted reference 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 ddded3c2..e51e6807 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,7 +6,8 @@ renders every user-defined definition (part, port, interface, requirement, action, and so on) 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, -membership, attribute-typing, and redefinition edges orthogonally between the boxes. The whole diagram — package +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` 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 @@ -21,19 +22,28 @@ box title and folder-tab geometry come from `BoxMetrics` in `DemaConsulting.Rend parameters. Layout constants (`MinBoxWidth`, `CharWidthFactor`) are declared as `private const` fields. Private records carry intermediate data: `DefBox` (a user definition with its computed size, keyword, supertype names, memberships, and compartments), `ModelEdge` (a resolved -specialization/membership/attribute-typing/redefinition relationship expressed by qualified -name, together with its target end marker and edge kind), `Location` (a located definition's +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). `FeatureMembership` (a private record) carries +ellipsis label onto the placed box after layout). `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 each owned feature's keyword, raw type reference (`TypeName`, nullable — a feature may declare a -redefinition with no explicit type annotation), simple `Name`, and raw -`RedefinedFeatureName` reference; `CollectMemberships` includes a feature when either `TypeName` -or `RedefinedFeatureName` is present. The private `EdgeKind` enumeration classifies each edge as -`Specialization`, `Membership`, `Typing`, or `Redefinition`; the `LineStyleForKind` helper maps -this kind to a rendered line style (dashed for `Typing`, solid for the others — including -`Redefinition`), so an attribute-typing dependency is visually distinct from the structural -relationships. +redefinition with no explicit type annotation), simple `Name`, raw +`RedefinedFeatureName` reference, and the raw `SubsettedFeatureNames` list (populated verbatim +from `SysmlFeatureNode.SupertypeNames` — a feature's `subsets`/`:>` targets, not a new AST field); +`CollectMemberships` includes a feature when `TypeName`, `RedefinedFeatureName`, or a non-empty +`SubsettedFeatureNames` is present. The private `EdgeKind` enumeration classifies each edge as +`Specialization`, `Membership`, `Typing`, `Redefinition`, `Subsetting`, `Connect`, `Allocate`, +`Dependency`, or `Binding`; `Subsetting` is a purely view-layer classification — it does not +correspond to a public `SysmlEdgeKind` — reusing `SupertypeNames` and the same owner-resolution +walk (`ResolveRedefinitionOwner`) already used for `Redefinition`. The `LineStyleForKind` helper +maps each kind to a rendered line style (dashed for `Typing`, `Allocate`, `Dependency`, and +`Subsetting`; solid for all others), so an attribute-typing/allocate/dependency/subsetting +dependency is visually distinct from the structural relationships. ##### Key Methods @@ -52,8 +62,10 @@ carries standalone `FilterExpressionText`, the method next parses it with A parse failure or unsupported Phase 1 construct does not abort layout: the first parser diagnostic message is remembered as the warning reason and the method continues rendering the unfiltered resolved scope. The remaining pipeline is unchanged: definitions are grouped by package -with `GroupByPackage`, the specialization/membership/attribute-typing/redefinition relationships are -resolved into qualified-name edges with `BuildModelEdges`, the single input `LayoutGraph` is built +with `GroupByPackage`, 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 `HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` call — passing the desired root-scope leaf algorithm through the options parameter (not @@ -61,10 +73,13 @@ passing the desired root-scope leaf algorithm through the options parameter (not 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 -`LayoutWarnings.ForUnevaluatedFilter` (only when standalone filter parsing/evaluation failed) with +`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 -successfully-evaluated bracket filter now has real narrowing effect and produces no warning) via +successfully-evaluated bracket filter now has real narrowing effect and produces no warning), and +`LayoutWarnings.ForDroppedRelationshipEdges(context.ViewName, dropped)` (one warning per +`Connect`/`Allocate`/`Dependency`/`Binding` edge `BuildModelEdges` could not map to two distinct +rendered boxes — see `BuildModelEdges` below) via the `LayoutTree with { Warnings = … }` record-copy idiom. ###### `CollectDefinitions(workspace, theme, scope)` @@ -81,21 +96,33 @@ and the longest compartment row. 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. -###### `BuildModelEdges(defs)` +###### `BuildModelEdges(defs, workspace, isScoped)` Resolves every specialization (subtype → supertype), structural membership (member-type → owner), -attribute-typing (owner → attribute-type), and redefinition (subtype → the owning definition of -the redefined feature) relationship — across every definition, regardless of -package — into a flat list of qualified-name `ModelEdge`s. Specialization edges carry an open -triangular end marker at the supertype; `part`/`port` memberships carry a filled diamond and `ref` -memberships a hollow diamond at the owner; other memberships are not drawn. In addition, each -`attribute` (or `enum`-typed attribute) feature whose type resolves to a definition in the view -contributes a **typing** edge from the owner to the attribute-type definition, carrying an open +attribute-typing (owner → attribute-type), redefinition (subtype → the owning definition of +the redefined feature), and subsetting (subtype → the owning definition of the subsetted +ancestor feature) relationship — across every definition, regardless of +package — into a flat list of qualified-name `ModelEdge`s, then appends `Connect`/`Allocate`/ +`Dependency`/`Binding` edges resolved directly from `workspace.Index.AllEdges` (the semantic +model's already-resolved edges — no re-resolution needed here). Specialization edges carry an open +triangular end marker at the supertype; `part`/`port` memberships carry a filled diamond; other +memberships are not drawn (the `ref` keyword no longer contributes a hollow-diamond membership +marker — see below). In addition, each `attribute` (or `enum`-typed attribute) feature whose type +resolves to a definition in the view contributes a **typing** edge from the owner to the +attribute-type definition, carrying an open chevron at the type end and rendered as a dashed line. Attribute typing is a usage-type dependency, not composition, so it uses the OMG dependency notation (dashed line with an open arrowhead) rather than a membership diamond, and it connects otherwise-disconnected attribute and enumeration definitions into the cluster near the definitions that reference them. Unresolved types and -self-references are skipped. Finally, each feature with a non-null `RedefinedFeatureName` +self-references are skipped. + +Each `ref`-keyword feature (previously rendered with an obsolete hollow-diamond membership marker — +a bug fixed by this unit) instead contributes a **dependency** edge from the owner to the +referenced type, sharing the exact same rendering (dashed line, open chevron, `EdgeKind.Dependency`) +as the new public `Dependency` edge kind below — per current OMG SysML v2 notation, a `ref` +feature is a usage-type dependency, not a diamond-marked structural membership. + +Each feature with a non-null `RedefinedFeatureName` contributes a **redefinition** edge from the subtype to the owning definition of the redefined feature, carrying a hollow-triangle-crossbar end marker at the owner and rendered as a solid line via `ResolveRedefinitionOwner`: a qualified reference (containing `::`) strips the text before the @@ -106,10 +133,82 @@ transitively up the chain (guarded by a `HashSet` of visited qualified n infinite loop on a malformed cyclic supertype graph) when the immediate supertype does not declare it. Neither resolving nor a self-referential result (`owner == def.QualifiedName`) produces an edge or a diagnostic — consistent with the existing `TryResolveQualified`-failure-is-silent -convention used by the other three edge kinds in this method. Whether an edge's endpoints actually +convention used by the other edge kinds in this method. + +Each entry in a feature's `SubsettedFeatureNames` (populated verbatim from +`SysmlFeatureNode.SupertypeNames` — see `FeatureMembership` above) contributes a **subsetting** +edge from the subtype to the owning definition of the subsetted (ancestor) feature, carrying a +hollow-triangle end marker (the same marker as `Specialization`, distinguished purely by the +dashed line style) at the owner, resolved by reusing `ResolveRedefinitionOwner` verbatim — a +subsetted-feature reference resolves identically to a redefined-feature reference, since both are +"a bare or qualified reference to a member the owner inherits" in SysML v2 semantics. +`Subsetting` is intentionally *not* a new public `SysmlEdgeKind`: it is derived entirely from +existing AST/resolver data at render time, per the project's explicit design decision to avoid +adding semantic-model surface area for a relationship that can be fully reconstructed from +`SupertypeNames` plus the existing redefinition-owner walk. + +Finally, `Connect`/`Allocate`/`Dependency`/`Binding` edges are read directly from +`workspace.Index.AllEdges`, one iteration over every `SysmlEdge` whose `Kind` matches one of the +four. Each endpoint (`SourceQualifiedName`/`TargetQualifiedName`) is mapped to its *owning rendered +box* via `ResolveOwningBox` (see below) — necessary because a `Connect`/`Binding` endpoint is +frequently a dotted feature-chain qualified name (e.g. `Drone::controller::power`), not itself a +definition, so it must be mapped up to the definition that actually owns the referenced feature +before it can become a graph edge endpoint. `Allocate` edges carry an open chevron at the target +and a `«allocate»` midpoint label; `Dependency` edges carry an open chevron with no label (matching +the `ref`-fix rendering above); `Connect` edges carry no end marker and no label; `Binding` edges +carry no end marker and an `=` midpoint label. An edge is only emitted when both endpoints resolve +to *distinct* boxes — a same-box result is a genuine self-loop and is dropped, exactly as every +other edge kind in this method already does for self-references. For the dominant real-world +corpus shape — two sibling features (e.g. ports) declared directly in their owning `part def`s, +referenced from an enclosing part via bare `part` usages with no per-instance nested redeclaration +— `ReferenceResolver`'s dotted-chain resolution now (as of the instance-path-preserving fix +documented in the `ReferenceResolver` design chapter) correctly returns an instance-relative +qualified name for each endpoint (e.g. `Drone::controller::power`, `Drone::battery::output`), which +`ResolveOwningBox`'s shortest-matching-prefix walk maps to two *distinct* boxes — this is the +normal, expected outcome for that shape, not a self-loop. A genuine same-box result now only arises +when both endpoints are truly features of the same enclosing definition (e.g. two sibling ports of +one part, with no distinguishing intermediate owner) — a rarer, legitimately self-referential +model shape. + +Every edge dropped by this method — whether because an endpoint failed to resolve to any rendered +box, or because both endpoints resolved to the same box — is also recorded (subject to the scoping +rule described in the next paragraph) as a `DroppedRelationshipEdge` (kind, raw source/target +reference, and a short human-readable reason), returned as `BuildModelEdges`'s second tuple element +and surfaced by `BuildLayout` via `LayoutWarnings.ForDroppedRelationshipEdges` — a defense-in-depth +diagnostic ensuring that any residual or future resolution gap remains visible in the view's +`Warnings` rather than being silently and invisibly dropped, as it was before this diagnostic was +added. An unresolved-endpoint drop is only reported when the view carries no `expose` scope +restriction: within an `expose`-scoped view, an endpoint whose owning box falls outside the exposed +subtree legitimately fails to resolve by design, and reporting it would be misleading noise for +ordinary, intentional scope narrowing (confirmed empirically while regenerating the gallery's +`expose`-scoped `BatterySubsystemView`, whose `Allocate`/`Dependency`/`Connect` edges reaching +outside the exposed `Battery` subtree produced exactly this noise before the scoping rule was +added). A genuine self-loop (both endpoints resolve, to the same box) is always reported, +regardless of scoping, since a same-box result can never be an intended outcome of `expose` +narrowing. + +Whether an edge's endpoints actually receive a graph node — i.e., were not depth-truncated — is decided later, in `BuildGraph`. +###### `ResolveOwningBox(qualifiedName, workspace, byQualified, bySimple)` + +Resolves the qualified name of the rendered box that "owns" a `Connect`/`Binding` endpoint +reference (also reused, degenerately, for `Allocate`/`Dependency` endpoints that are already +definition names). If the reference already names a rendered definition, it resolves directly +(the common, simple case). Otherwise, the reference is a dotted-feature-chain qualified name +(e.g. `Drone::controller::power`, produced by `ReferenceResolver`'s feature-chain walk, which +always emits `"::"`-joined segments — never the raw `.`-separated source text). The method walks +successively shorter `"::"`-split prefixes of that name, from **longest to shortest**, looking for +a `SysmlFeatureNode` declaration whose own resolved `Typing` edge targets a rendered box; each +match overwrites the running candidate (it does not break early), so the **shortest** matching +prefix — the feature immediately, directly owned by the enclosing rendered definition — wins. +This "shortest wins" rule is essential: a naive "walk to the nearest enclosing definition" (i.e., +longest-prefix-wins) would resolve *both* sides of the dominant real-world corpus shape — e.g. +`connect controller.power to battery.output;` written inside a single enclosing `part def Drone` +— 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)` Builds the single input `LayoutGraph`, setting `CoreOptions.MergeParallelEdges` to `false` on the @@ -171,19 +270,23 @@ produces valid geometry, so no crossing warnings are emitted. algorithms it delegates to per scope. - `BoxMetrics` (`DemaConsulting.Rendering.Abstractions`) — box title-area and folder-tab geometry. - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. -- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode` (Semantic subsystem) — model input. +- `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode` (Semantic subsystem) — model input; + `SysmlWorkspace.Index.AllEdges` and `SysmlEdgeKind.Connect`/`Allocate`/`Dependency`/`Binding` + additionally consumed by `BuildModelEdges`/`ResolveOwningBox`. - `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope` and `IsInSubjectScope` supply the shared `expose`-scoping used by `BuildLayout` and `CollectDefinitions`. - `FilterExpressionParser` and `FilterExpressionEvaluator` (Filtering subsystem) — parse and evaluate standalone `filter [];` statements over the already expose-scoped definition set. -- `LayoutWarnings` (Layout Internal subsystem) — `ForUnevaluatedFilter` and - `ForUnevaluatedExposeBracketFilter` supply the warning text for standalone-filter fallback and - (Phase 2a) failed expose bracket filters only. +- `LayoutWarnings` (Layout Internal subsystem) — `ForUnevaluatedFilter`, + `ForUnevaluatedExposeBracketFilter`, and `ForDroppedRelationshipEdges` supply the warning text + for standalone-filter fallback, (Phase 2a) failed expose bracket filters, and dropped + `Connect`/`Allocate`/`Dependency`/`Binding` edges, respectively. - The `LayoutTree`, `LayoutBox`, `LayoutCompartment`, `LayoutLine`, `LayoutLabel`, and `Point2D` data types (`DemaConsulting.Rendering`). - `FeatureMembership` (private record) — carries the keyword, nullable type reference, simple - name, and nullable redefined-feature reference of one owned feature. + name, nullable redefined-feature reference, and subsetted-feature-name list of one owned + feature. ##### Callers 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 8b212a04..792ef68c 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -27,6 +27,8 @@ stack with `::` to form the fully-qualified name. | `VisitAllocationUsage` | `AllocationUsageContext` | `SysmlConnectionNode` (`ConnectionKeyword = "allocation"`) | | `VisitSatisfyRequirementUsage` | `SatisfyRequirementUsageContext` | `SysmlSatisfyNode` | | `VisitRequirementUsage` | `RequirementUsageContext` | `SysmlFeatureNode` (`FeatureKeyword = "requirement"`) | +| `VisitDependency` | `DependencyContext` | `SysmlDependencyNode` | +| `VisitBindingConnectorAsUsage` | `BindingConnectorAsUsageContext` | `SysmlConnectionNode` (kind `binding`) | `GetDeclaredName(IdentificationContext)` handles the three grammar alternatives: @@ -63,6 +65,19 @@ forms identically without needing to branch on which token was used. The raw ref captured verbatim — including qualified `Owner::feature` forms — with no resolution attempted; resolution happens later, in `ReferenceResolver`. +When a usage has no explicit declared name (`GetDeclaredName` returns `null`) but does have a +redefinition (`redefined is not null`), `BuildUsageNode` derives an implicit name via +`SimpleNameFromReference(redefined)` — the trailing `::`- or `.`-separated segment of the +redefined feature's reference text, whichever separator occurs furthest to the right (an +`ownedRedefinition` is grammatically `qualifiedName ( DOT qualifiedName )*`, so the reference can +be a dotted feature-chain path like `tank.fuelTankPort`, not just a `::`-qualified name) — and +uses this `effectiveName` everywhere the declared name would otherwise be used: the +namespace-stack push/pop, the constructed node's `QualifiedName`, and its `Name` property. This +mirrors SysML v2's own naming rule that an implicitly-named redefining usage inherits the +redefined feature's name (e.g. `port redefines fuelTankPort { ... }` is named `fuelTankPort`), +and allows such usages — and any references to them (including `bind` connector ends) — to +resolve correctly instead of remaining anonymous and unresolvable. + `BuildUsageNode` also calls `ExtractSubsettingTargetNames(decl?.featureSpecializationPart())`, setting the result on the constructed `SysmlFeatureNode`'s inherited `SupertypeNames` property — mirroring `ExtractRedefinedFeature`'s structure (first checking @@ -113,6 +128,26 @@ surfaced and was fixed during this unit's implementation for the `CollectTypeBod `AllocationUsageDeclarationContext.connectorPart()` exposes the exact same `ConnectorPartContext` shape as `ConnectionUsageContext.connectorPart()`, so no new endpoint-extraction logic is needed. +`VisitBindingConnectorAsUsage` builds a `SysmlConnectionNode` with `ConnectionKeyword = "binding"` +for the common `bind A = B;` (`bindingConnectorAsUsage`) grammar shape, reusing the shared +`ConnectorEndReference` helper against each of the rule's `connectorEndMember()` entries — the +same helper `connectionUsage`'s endpoint extraction already uses. The longer +`bindingConnector`/`typeBody` grammar form has zero corpus evidence and is a documented, +intentional non-goal (not attempted). `ReferenceResolver` resolves `"binding"`-keyword +`SysmlConnectionNode` endpoints via the same dotted-feature-chain walk it already applies to +`"connection"`/`"message"`. + +`VisitDependency` builds a `SysmlDependencyNode` for a standalone `dependency A, B to C, D;` +declaration. The grammar's `dependency` rule exposes a single flat `qualifiedName()` list (no +separate "from" vs. "to" sub-rules), so `VisitDependency` splits it positionally: every +`qualifiedName()` whose start token index is before the `TO()` terminal's token index is a +"from" (client) name, and every one after is a "to" (supplier) name. This also correctly handles +the grammar's optional `FROM` keyword (e.g. `dependency z to x, y;`, with no explicit `from` +before `z`) since the split is driven purely by position relative to `TO`, never by the +presence/absence of the `FROM` keyword token itself. `ReferenceResolver` resolves every +`FromNames` entry against every `ToNames` entry (a cross product), emitting one +`SysmlEdgeKind.Dependency` edge per resolved pair. + `VisitSatisfyRequirementUsage` builds a `SysmlSatisfyNode` for `satisfy X by Y;` usages. The satisfied requirement's raw reference text is taken from `ownedReferenceSubsetting()` when the `satisfy ` form is used, falling back to the declared/typed name of the @@ -220,8 +255,8 @@ results without propagating failures. dispatch over the CST. - `SysMLv2Parser` — provides all CST context types consumed by the visitor methods. - `SysmlNode` hierarchy (`SysmlPackageNode`, `SysmlDefinitionNode`, `SysmlViewNode`, - `SysmlViewpointNode`, `SysmlSatisfyNode`, `SysmlConnectionNode`) — AST node types constructed - by the visitor. + `SysmlViewpointNode`, `SysmlSatisfyNode`, `SysmlConnectionNode`, `SysmlDependencyNode`) — AST + node types constructed by the visitor. - `SysmlAnnotation` / `SysmlAnnotationKind` — captured comment/documentation data attached to `SysmlNode.Annotations`. diff --git a/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md b/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md index c307410a..c38f6b7c 100644 --- a/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md +++ b/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md @@ -8,14 +8,15 @@ files and uses depth-first search to detect cycles. 2. **Reference resolution (pass 1)** — checks each `SupertypeName`, `SysmlFeatureNode.FeatureTyping`, `SysmlFeatureNode.RedefinedFeatureName`, `ImportedName`, `VerifiedRequirementNames` entry, - `SysmlSatisfyNode` subject/requirement, and `SysmlConnectionNode` - (`ConnectionKeyword == "allocation"`) endpoint in all AST nodes against + `SysmlSatisfyNode` subject/requirement, `SysmlConnectionNode` + (`ConnectionKeyword == "allocation"`) endpoint, and `SysmlDependencyNode` from/to name lists + in all AST nodes against the symbol table, emitting a Warning for any name not found and recording a `SysmlEdge` for any name (or pair of names) that resolves. 3. **Feature-chain resolution (pass 2)** — after pass 1 has completed for every file root, resolves dotted feature chains (e.g. `engine.fuelPort`) referenced by `SysmlConnectionNode` - (`ConnectionKeyword == "connection"` or `"message"`) endpoints and `SysmlTransitionNode` - `Source`/`Target` into `Connect`/`Transition`-kind edges. + (`ConnectionKeyword == "connection"`, `"message"`, or `"binding"`) endpoints and + `SysmlTransitionNode` `Source`/`Target` into `Connect`/`Binding`/`Transition`-kind edges. ##### Import Graph @@ -71,10 +72,12 @@ guarantees every `Typing`/`Supertype` edge needed by the walk already exists. `ResolveFeatureChains` mirrors `ResolveNode`'s namespace-stack push/pop condition exactly (`(node is SysmlPackageNode or SysmlDefinitionNode or SysmlFeatureNode) && node.Name is not null`) so that segment-0 resolution scope cannot silently diverge between the two passes. For each -`SysmlConnectionNode` with `ConnectionKeyword` `"connection"` or `"message"` (the `"allocation"` -variant is excluded — it keeps its existing, unit-3, single-segment-only behavior), and for each +`SysmlConnectionNode` with `ConnectionKeyword` `"connection"`, `"message"`, or `"binding"` (the +`"allocation"` variant is excluded — it keeps its existing, unit-3, single-segment-only +behavior), and for each `SysmlTransitionNode`, both sides (`EndpointA`/`EndpointB` or `Source`/`Target`) are resolved via -`TryResolveFeatureChain`, and a `Connect`/`Transition` edge is emitted only when **both** sides +`TryResolveFeatureChain`, and a `Connect`/`Binding`/`Transition` edge is emitted only when **both** +sides resolve — mirroring the existing Satisfy/Allocate both-sides-must-resolve contract. New edges are appended to `node.ResolvedEdges` (pass-1 edges, if any, are preserved) and to the aggregate edge list. @@ -84,12 +87,31 @@ reference text on `.`. Segment 0 is resolved via the existing `TryResolve` four- participates in the same scope/import resolution as any other single-name reference); a single-segment "chain" is handled by the remaining-segment loop simply never executing. Each subsequent segment is resolved relative to the previous segment's node (looked up via -`SymbolTable.Lookup`) using `FindFeatureMember`. +`SymbolTable.Lookup`) using `FindFeatureMember`. Two parallel accumulators are tracked across the +loop: `current` (the real declared node's qualified name, which continues to drive the *next* +segment's `SymbolTable.Lookup` — this part of the walk is unchanged) and `instancePath` (the value +ultimately returned as `resolvedName`). For a segment resolved via `FindFeatureMember`'s +direct-child branch, `instancePath` is simply set to the member's own (already instance-relative) +qualified name — unchanged from before. For a segment resolved via the type-hierarchy fallback +branch (`viaTypeFallback == true`, see `FindFeatureMember` below), `instancePath` is instead set to +`{previous instancePath}::{raw segment text}` — preserving the instance-relative path rather than +collapsing to the type's own declared qualified name. This distinction matters for the dominant +real-world `connect`/`bind` shape: two sibling features (e.g. ports) declared directly in their +owning `part def`s, referenced from an enclosing part via bare usages with no per-instance nested +redeclaration — every such reference resolves via the fallback branch, and without this +`instancePath` accumulator the returned qualified name would collapse to the shared port type's own +declared path (e.g. `PowerPort`'s owning type's own `power`/`output` declaration), causing +`GeneralViewLayoutStrategy.ResolveOwningBox` to (incorrectly) map both endpoints to the same box. -`FindFeatureMember(node, name)` tries `node`'s own direct children first — an inline nested usage -or redefinition shadows a same-named definition-level member (confirmed by the OMG fixture -`2c-PartsInterconnection-MultipleDecompositions.sysml`'s `port :>> pe = c1.pb` pattern) — falling -back to the member's `Typing`-edge target's own hierarchy only when no direct child matches. +`FindFeatureMember(node, name, out viaTypeFallback)` tries `node`'s own direct children first — an +inline nested usage or redefinition shadows a same-named definition-level member (confirmed by the +OMG fixture `2c-PartsInterconnection-MultipleDecompositions.sysml`'s `port :>> pe = c1.pb` +pattern) — setting `viaTypeFallback = false` and returning immediately when found. Only when no +direct child matches does it fall back to the member's `Typing`-edge target's own hierarchy, +setting `viaTypeFallback = true` for that branch; the returned node's own `QualifiedName` in this +case is the *type's own* declared path, not instance-relative — it is the caller +(`TryResolveFeatureChain`) that reconstructs the instance-relative path using `viaTypeFallback`, as +described above. `FindMemberInTypeHierarchy(typeNode, name, visited)` finds a member in `typeNode`'s own direct children, or — recursively — in any `Supertype`-edge ancestor's direct children, walking the @@ -139,6 +161,14 @@ no edge): = resolved first end, `Target` = resolved second end) only when both ends resolve, using the identical both-sides-must-resolve contract as `Satisfy`. Regular `"connection"`/`"message"` keyword variants remain intentionally unresolved (out of scope for this unit). +- **`SysmlDependencyNode` (Dependency)** — resolves every entry in `FromNames` and every entry in + `ToNames` independently (each unresolvable name produces its own Warning, per the + `resolvedInFile` de-duplication rule), then emits one `SysmlEdgeKind.Dependency` edge per + resolved (from, to) pair — a cross product, per the grammar's list-of-lists shape (e.g. + `dependency a, b to c, d;` resolves to up to 4 edges). Unlike the two-sided + Satisfy/Allocate/Connect contract, a `Dependency` edge is emitted per-pair rather than + all-or-nothing: an unresolvable name on one side does not suppress edges for the other + resolvable names. - **`SysmlViewNode` (Expose)** — resolves each `GetExposedNames()` entry (the `QualifiedName` of each `ExposeMember`) into its own `SysmlEdgeKind.Expose` edge, or the standard unresolved-reference Warning diagnostic when it does not resolve. An `ExposeMember`'s own @@ -222,9 +252,11 @@ unresolved names are present. - `SysmlNode` hierarchy — traversed to collect `SupertypeNames`, `ImportedNames`, and `VerifiedRequirementNames`; checks for `SysmlFeatureNode.FeatureTyping` and `SysmlFeatureNode.RedefinedFeatureName`, `SysmlSatisfyNode` - (`SubjectName`/`RequirementName`), `SysmlConnectionNode` with `ConnectionKeyword == + (`SubjectName`/`RequirementName`), `SysmlDependencyNode` (`FromNames`/`ToNames`), + `SysmlConnectionNode` with `ConnectionKeyword == "allocation"` (`EndpointA`/`EndpointB`), `SysmlConnectionNode` with `ConnectionKeyword == - "connection"` or `"message"`, `SysmlTransitionNode` (`Source`/`Target`), and `SysmlViewNode` + "connection"`, `"message"`, or `"binding"`, `SysmlTransitionNode` (`Source`/`Target`), and + `SysmlViewNode` (`GetExposedNames()`; `RenderTargetName`/`FilterExpressionText`/each `ExposeMember`'s `BracketFilterExpressionText` are never read); reads `ResolvedEdges` (`Typing`/`Supertype` kinds during feature-chain resolution; `Supertype` diff --git a/docs/design/sysml2-tools-language/semantic/model/sysml-edge.md b/docs/design/sysml2-tools-language/semantic/model/sysml-edge.md index 87b5198f..12132972 100644 --- a/docs/design/sysml2-tools-language/semantic/model/sysml-edge.md +++ b/docs/design/sysml2-tools-language/semantic/model/sysml-edge.md @@ -5,11 +5,12 @@ `SysmlEdge` and `SysmlEdgeKind` model a single resolved directed reference between two qualified names in the semantic model. Edges are produced by `ReferenceResolver` while walking supertype, feature-typing, redefinition, import, satisfy, verify, allocate, connect, -transition, and expose references, and are the raw material indexed by `SemanticIndex`. +transition, expose, metadata-typing, dependency, and binding references, and are the raw +material indexed by `SemanticIndex`. ##### Types -`SysmlEdgeKind` is an enum with ten members: +`SysmlEdgeKind` is an enum with thirteen members: - `Supertype` — a specialization reference (`SupertypeNames` / `specializes` / `:>`). - `Typing` — a feature typing reference (`SysmlFeatureNode.FeatureTyping`, the type after `:`). @@ -48,6 +49,23 @@ transition, and expose references, and are the raw material indexed by `Semantic targeting the resolved redefined-feature reference. Rendered by `GeneralViewLayoutStrategy` as a solid line with a hollow-triangle-crossbar end marker at the owning definition of the redefined feature. +- `MetadataType` — a metadata annotation's type reference (`SysmlMetadataNode.TypeReference` / + `@Type` / `{@Type{...}}`), sourced from the annotation and targeting the resolved `metadata + def` declaration it references. +- `Dependency` — a standalone dependency declaration (`dependency A, B to C, D;`), sourced from + each resolvable name in `SysmlDependencyNode.FromNames` and targeting each resolvable name in + `SysmlDependencyNode.ToNames`, one `Dependency` edge per resolved (from, to) pair (cross + product); an unresolvable name on either side is skipped (with a Warning diagnostic) rather + than blocking the other pairs. Rendered by `GeneralViewLayoutStrategy` as a dashed line with an + open-chevron end marker at the target (depended-upon) box; the pre-existing `ref`-typed + membership case is rendered with the same dashed/open-chevron style (see + `general-view-layout-strategy.md`). +- `Binding` — a resolved binding-connector reference (`bind A = B;`), sourced from the first + connector end and targeting the second (`SysmlConnectionNode` with + `ConnectionKeyword == "binding"`, reusing the `EndpointA`/`EndpointB` shape and the same + dotted-feature-chain walk as `Connect`); recorded only when both endpoints resolve. Rendered + by `GeneralViewLayoutStrategy` as a solid line with no end marker and an optional `=` midpoint + label. `SysmlEdge` is a sealed positional record with three properties: 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 e3d02d52..3f0ffcb2 100644 --- a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md @@ -4,7 +4,7 @@ `SysmlNode` is the abstract base class for all SysML/KerML AST nodes. Concrete subtypes represent packages, definitions, features, imports, applied metadata annotations, views, viewpoints, -connections, transitions, and requirement-satisfaction usages. +connections, transitions, dependency declarations, and requirement-satisfaction usages. ##### Class Hierarchy @@ -21,6 +21,7 @@ connections, transitions, and requirement-satisfaction usages. | `SysmlConnectionNode` | Connection/binding/allocation usage; adds ConnectionKeyword, EndpointA, EndpointB | | `SysmlTransitionNode` | State transition; adds Source, Target, Guard | | `SysmlSatisfyNode` | `satisfy X by Y;` requirement-satisfaction usage; adds RequirementName, SubjectName | +| `SysmlDependencyNode` | Standalone `dependency A, B to C, D;` declaration; adds FromNames, ToNames | ##### Properties @@ -29,7 +30,14 @@ All nodes carry: - `Name` — simple (unqualified) name, or null if anonymous. - `QualifiedName` — fully-qualified name in containing namespace. - `Children` — nested AST nodes. -- `SupertypeNames` — qualified names of supertypes referenced via `specializes` / `:>`. +- `SupertypeNames` — qualified names of supertypes referenced via `specializes` / `:>`. For a + `SysmlFeatureNode`, this list is also reused, unresolved, by `GeneralViewLayoutStrategy` as the + raw source of its private view-layer `Subsetting` classification (`subsets ;` / + `:> ` on a feature) — there is no separate `SysmlEdgeKind.Subsetting` or dedicated AST + field; the layout strategy re-derives which entries are subsetting targets versus ordinary + specialization targets by walking the same feature-typing/redefinition-owner resolution it + already performs for `Redefinition` edges. See `general-view-layout-strategy.md` for the exact + algorithm. - `ImportedNames` — qualified/dotted-name text of imported namespaces or members; populated by `AstBuilder.VisitImportRule` for `SysmlImportNode` (mirroring `ImportedNamespace`), and empty for all other node types today. diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 9b8fed65..c825e59a 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -33,6 +33,17 @@ ports, parts) and specialization edges. Definitions are placed by a layered (`attribute :>> maxThrust : Mass;`), demonstrating the hollow-triangle-crossbar marker used for `redefines`/`:>>` relationships. +`Drone`'s `connect controller.power to battery.output;` statement demonstrates +the plain, unmarked solid line used for `connect` between two sibling parts' +ports. `RacingDrone`'s `part frontMotors : Motor[2] subsets motors;` narrows +`Drone`'s inherited `motors` feature, demonstrating the dashed hollow-triangle +marker used for `subsets`/`:>` feature subsetting. `allocate FlightTimeRequirement +to Battery;` demonstrates the dashed open-chevron line carrying the +`«allocate»` stereotype label used for allocation relationships, while the +standalone `dependency FlightController to Battery;` statement demonstrates the +same dashed open-chevron line without a label, used for general OMG +dependency relationships. + Model: [`models/01-drone-general.sysml`](models/01-drone-general.sysml) · SVG: [`svg/DroneGeneralView.svg`](svg/DroneGeneralView.svg) diff --git a/docs/gallery/models/01-drone-general.sysml b/docs/gallery/models/01-drone-general.sysml index 12b7f9e6..00150a4b 100644 --- a/docs/gallery/models/01-drone-general.sysml +++ b/docs/gallery/models/01-drone-general.sysml @@ -63,11 +63,31 @@ package QuadcopterDrone { part propellers : Propeller[4]; part imu : ImuSensor; part gps : GpsSensor; + + // A connect relationship between two sibling parts' ports, demonstrating + // the plain solid line (no end marker) used for `connect`. + connect controller.power to battery.output; + } + + // RacingDrone subsets Drone's "motors" feature with its own "frontMotors" + // usage, demonstrating the dashed hollow-triangle marker used for + // `subsets`/`:>` feature subsetting (distinct from redefinition). + part def RacingDrone :> Drone { + part frontMotors : Motor[2] subsets motors; } // ===== A requirement ===== requirement def FlightTimeRequirement; + // An allocate relationship from the requirement to the part it is + // allocated to, demonstrating the dashed open-chevron line with the + // «allocate» stereotype label. + allocate FlightTimeRequirement to Battery; + + // A standalone dependency declaration, demonstrating the dashed + // open-chevron line (no label) used for a general OMG dependency. + dependency FlightController to Battery; + view def DroneGeneralView {} // A view-scoped rendering example: `expose` narrows the diagram to just diff --git a/docs/gallery/png/BatterySubsystemView.png b/docs/gallery/png/BatterySubsystemView.png index fa0eb4c9..43f76787 100644 Binary files a/docs/gallery/png/BatterySubsystemView.png and b/docs/gallery/png/BatterySubsystemView.png differ diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png index beb99dd5..d03431bc 100644 Binary files a/docs/gallery/png/DroneGeneralView.png and b/docs/gallery/png/DroneGeneralView.png differ diff --git a/docs/gallery/svg/DroneGeneralView.svg b/docs/gallery/svg/DroneGeneralView.svg index 47f00b21..93b1d0ce 100644 --- a/docs/gallery/svg/DroneGeneralView.svg +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -1,4 +1,4 @@ - + @@ -30,132 +30,145 @@ - - «package» - QuadcopterDrone + + «package» + QuadcopterDrone - «interface def» - PowerBus - - «interface def» - DataBus - - «port def» - PowerPort - - «port def» - TelemetryPort - - «port def» - MotorControlPort - - «port def» - SensorPort - - «attribute def» - Mass - - «attribute def» - Voltage - - «enum def» - FlightMode - - «part def» - Battery - - attributes - capacity : Voltage - - ports - output : PowerPort - - «part def» - FlightController - - attributes - mode : FlightMode - - ports - power : PowerPort - telemetry : TelemetryPort - motors : MotorControlPort - sensors : SensorPort - - «part def» - Motor - - attributes - maxThrust : Mass - - ports - control : MotorControlPort - - «part def» - Propeller - - «part def» - ImuSensor - - ports - data : SensorPort - - «part def» - GpsSensor - - ports - data : SensorPort - - «part def» - Frame - - «part def» - RacingMotor - - attributes - : Mass - - «part def» - EnduranceBattery - - «part def» - Drone - - attributes - totalMass : Mass - - parts - airframe : Frame - battery : Battery - controller : FlightController - motors : Motor [4] - propellers : Propeller [4] - imu : ImuSensor - gps : GpsSensor - - «requirement def» - FlightTimeRequirement - - - - - - - - - - - - - - - - - - - - - - - + «interface def» + PowerBus + + «interface def» + DataBus + + «port def» + PowerPort + + «port def» + TelemetryPort + + «port def» + MotorControlPort + + «port def» + SensorPort + + «attribute def» + Mass + + «attribute def» + Voltage + + «enum def» + FlightMode + + «part def» + Battery + + attributes + capacity : Voltage + + ports + output : PowerPort + + «part def» + FlightController + + attributes + mode : FlightMode + + ports + power : PowerPort + telemetry : TelemetryPort + motors : MotorControlPort + sensors : SensorPort + + «part def» + Motor + + attributes + maxThrust : Mass + + ports + control : MotorControlPort + + «part def» + Propeller + + «part def» + ImuSensor + + ports + data : SensorPort + + «part def» + GpsSensor + + ports + data : SensorPort + + «part def» + Frame + + «part def» + RacingMotor + + attributes + : Mass + + «part def» + EnduranceBattery + + «part def» + Drone + + attributes + totalMass : Mass + + parts + airframe : Frame + battery : Battery + controller : FlightController + motors : Motor [4] + propellers : Propeller [4] + imu : ImuSensor + gps : GpsSensor + + «part def» + RacingDrone + + parts + frontMotors : Motor [2] + + «requirement def» + FlightTimeRequirement + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «allocate» 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 ce4d8133..fd89efcb 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 @@ -91,13 +91,101 @@ sections: - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-ReferenceMembership title: >- GeneralViewLayoutStrategy shall connect a definition to the type of each owned ref - feature with a hollow-diamond end marker at the owning definition end. + feature with a dashed line ending in an open-chevron end marker at the referenced type, + sharing the same rendering as a standalone Dependency edge. justification: | - Reference membership (e.g. a requirement's subject feature) is a distinct SysML v2 - relationship shown with a hollow diamond. Rendering it as a separate edge distinguishes - it visually from structural containment (filled diamond). + A `ref` (reference usage) feature is a usage-type dependency per current OMG SysML v2 + notation, not a diamond-marked structural membership. The previous hollow-diamond + membership marker for `ref` was an obsolete notation and has been removed; rendering + `ref` with the same dashed/open-chevron style as a `Dependency` edge keeps both visually + identical, matching their shared semantic meaning. tests: - - GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesHollowDiamondEdge + - GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesDependencyEdge + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Connect + title: >- + GeneralViewLayoutStrategy shall connect the owning rendered box of each resolved + Connect edge's source endpoint to the owning rendered box of its target endpoint with + a solid line and no end marker, and shall draw no edge when both endpoints resolve to + the same box (a genuine self-loop). + justification: | + `connect A to B;` (and a `message`'s from/to events) is a core SysML v2 relationship; + rendering it with a plain solid line (no marker) matches OMG connector notation and + distinguishes it from every other edge kind, all of which carry either a marker, a + dashed style, or both. Suppressing same-box self-loops is required because a rarer, + genuinely self-referential model shape (e.g. two sibling ports of the very same + enclosing definition, with no distinguishing intermediate owner) would otherwise draw + a self-loop with no informational value. The dominant real-world corpus shape — two + sibling features declared directly in their owning `part def`s, referenced from an + enclosing part via bare usages with no per-instance nested redeclaration — resolves to + two *distinct* boxes (not a self-loop), verified end to end against the real + `WorkspaceLoader`/`BuildLayout` pipeline. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Connect_DifferentOwningTypes_ProducesUnmarkedSolidEdge + - GeneralViewLayoutStrategy_BuildLayout_Connect_SameOwningType_ProducesNoEdge + - GeneralViewLayoutStrategy_BuildLayout_ConnectDominantShape_RealWorkspaceLoader_ProducesDistinctBoxes + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Allocate + title: >- + GeneralViewLayoutStrategy shall connect the owning rendered box of each resolved + Allocate edge's source endpoint to the owning rendered box of its target endpoint with + a dashed line ending in an open chevron at the target, carrying a «allocate» + midpoint label. + justification: | + `allocate A to B;` is the SysML v2 allocation relationship, conventionally rendered as + a dashed dependency-style edge with an explicit «allocate» stereotype label + distinguishing it from an unlabeled Dependency edge that shares the same line style and + marker. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Allocate_ProducesDashedChevronEdgeWithLabel + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Dependency + title: >- + GeneralViewLayoutStrategy shall connect the owning rendered box of each resolved + Dependency edge's source endpoint to the owning rendered box of its target endpoint + with a dashed line ending in an open chevron at the target (the depended-upon + element), with no midpoint label. + justification: | + A standalone `dependency A to B;` declaration is the general OMG dependency + relationship, rendered per convention as a dashed line with an open arrowhead pointing + at the depended-upon element — the same rendering the `ref`-fix reuses, since both are + semantically a usage-type dependency. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Dependency_ProducesDashedChevronEdge + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Binding + title: >- + GeneralViewLayoutStrategy shall connect the owning rendered box of each resolved + Binding edge's source endpoint to the owning rendered box of its target endpoint with + a solid line and no end marker, carrying a = midpoint label. + justification: | + `bind A = B;` (a binding connector) asserts equality between its two connector ends; + rendering it as a solid, unmarked line (matching `Connect`'s notation, since both are + connector-shaped relationships) with an explicit = midpoint label distinguishes + it from a plain `Connect` edge while preserving the shared connector notation. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Binding_ProducesSolidEdgeWithEqualsLabel + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Subsetting + title: >- + GeneralViewLayoutStrategy shall connect a subtype feature that declares a `subsets`/`:>` + reference to the owning definition of the subsetted (ancestor) feature — resolved via + the same owner-resolution walk used for Redefinition — with a dashed line ending in a + hollow-triangle end marker at the owner, and shall draw no edge when the reference + cannot be resolved or resolves back to the subtype's own owning definition. + justification: | + Subsetting (`subsets`/`:>` on a feature) is a distinct SysML v2 relationship from + redefinition, but shares the same "reference to an inherited member" resolution shape; + reusing `ResolveRedefinitionOwner` verbatim avoids adding a new public `SysmlEdgeKind` + or duplicating the owner-resolution algorithm, per this feature's explicit design + decision to keep Subsetting a private, view-layer-only classification derived from the + existing `SupertypeNames`/redefinition-owner machinery. The hollow-triangle marker + matches Specialization's marker (both express an "is-a-kind-of" narrowing), while the + dashed line style distinguishes it from Specialization's solid line, mirroring how + Typing is distinguished from Membership. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Subsetting_CrossesSpecializationBoundary_ProducesDashedHollowTriangleEdge + - GeneralViewLayoutStrategy_BuildLayout_SelfReferentialSubsetting_ProducesNoEdge - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-NonStructuralMembership title: >- @@ -234,6 +322,27 @@ sections: tests: - GeneralViewLayoutStrategy_BuildLayout_FilterExpressionPresent_EmitsNotYetEvaluatedWarning + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-DroppedRelationshipEdgeWarning + title: >- + When a Connect/Allocate/Dependency/Binding edge's endpoint fails to resolve to a + rendered box, or both endpoints resolve to the same box (a genuine self-loop), + GeneralViewLayoutStrategy shall emit a warning naming the edge kind, its raw + source/target references, and a short reason, rather than silently dropping the edge. + An unresolved-endpoint drop caused solely by the endpoint falling outside an active + `expose` scope narrowing is expected behavior and shall not be reported. + justification: | + A dropped relationship edge with no rendered box was previously indistinguishable from + correct behavior — the diagram simply omitted it, with no signal to the user that a + model reference failed to resolve or collapsed into an unintended self-loop. This + defense-in-depth diagnostic makes any residual or future resolution gap visible, + matching the same visible-not-silent principle already applied by + `ForUnevaluatedFilter`/`ForUnevaluatedExposeBracketFilter`. Excluding drops caused by + ordinary `expose` scope narrowing avoids misleading noise for a relationship whose + endpoint is simply, and correctly, outside the exposed subtree by design. + tests: + - GeneralViewLayoutStrategy_BuildLayout_Connect_SameOwningType_ProducesNoEdge + - GeneralViewLayoutStrategy_BuildLayout_ConnectDominantShape_RealWorkspaceLoader_ProducesDistinctBoxes + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-Redefinition-QualifiedForm title: >- GeneralViewLayoutStrategy shall connect a subtype feature that declares a qualified diff --git a/docs/reqstream/sysml2-tools-language.yaml b/docs/reqstream/sysml2-tools-language.yaml index cdee4a03..44e34032 100644 --- a/docs/reqstream/sysml2-tools-language.yaml +++ b/docs/reqstream/sysml2-tools-language.yaml @@ -125,8 +125,9 @@ sections: `rearAxle.leftHalfAxle.axleToWheelPort`) referenced by connection/message endpoints and transition source/target into Connect/Transition-kind edges recorded on the semantic index, walking direct-child membership first and typing/supertype-inherited membership - second, gracefully degrading to a Warning diagnostic with no edge when any side is - unresolvable. + second, preserving an instance-relative qualified name for the resolved endpoint even + when a segment resolves via the typing/supertype-inherited fallback branch, and + gracefully degrading to a Warning diagnostic with no edge when any side is unresolvable. justification: | Connect/Transition edges are the connector/state-topology data the forthcoming `query` command's uses/used-by/impact verbs need to answer "what is this port connected to" and @@ -137,6 +138,7 @@ sections: - WorkspaceLoader_LoadAsync_ConnectionTwoSegmentChain_ResolvesViaTypingFallback - WorkspaceLoader_LoadAsync_ConnectionThreeSegmentChain_MixesDirectChildAndTypingFallback - WorkspaceLoader_LoadAsync_ConnectionChain_ResolvesInheritedFeatureViaSupertype + - WorkspaceLoader_LoadAsync_ConnectionDominantShape_ResolvesDistinctInstancePaths - WorkspaceLoader_LoadAsync_ConnectionUnresolvedEndpoint_ProducesWarningNoEdge - WorkspaceLoader_LoadAsync_MessageEndpoints_RecordsConnectEdge - WorkspaceLoader_LoadAsync_TransitionSourceAndTarget_RecordsTransitionEdge 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 335590bf..fe528564 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -144,6 +144,29 @@ sections: - WorkspaceLoader_LoadAsync_QualifiedRedefinition_CapturesRawText - WorkspaceLoader_LoadAsync_NoRedefinition_RedefinedFeatureNameIsNull + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ImplicitRedefinitionName + title: >- + AstBuilder shall assign an implicitly-named redefining usage (one with no explicit + declared name but a non-null RedefinedFeatureName) an effective name derived from the + redefined feature's simple (trailing `::`-segment) name, using it in place of the + missing declared name for namespace registration, QualifiedName, and the node's Name + property. + justification: | + SysML v2 allows a redefining usage to omit its own declared name, in which case it + takes the redefined feature's name (e.g. `port redefines fuelTankPort { item redefines + fuelSupply; }` is named `fuelTankPort`). Without this fallback, such usages were built + as anonymous nodes never registered in the symbol table, so any subsequent reference + to them (including `bind` connector ends) failed to resolve — reproduced by the OMG + corpus fixture `BindingConnectorsExample-1.sysml`, whose `bind` statements reference an + implicitly-named `port redefines fuelTankPort { ... }` usage. Because `ownedRedefinition` + is grammatically `qualifiedName ( DOT qualifiedName )*`, the redefined reference can + itself be a dot-chained feature path (e.g. `tank.fuelTankPort`); the derivation takes + only the trailing simple-name segment, not the whole dotted or qualified reference text. + tests: + - WorkspaceLoader_LoadAsync_BindingViaImplicitlyNamedRedefinedUsage_RecordsBindingEdge + - WorkspaceLoader_LoadAsync_ImplicitNameFromDottedRedefinitionChain_UsesTrailingSegment + - Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-MetadataAnnotations title: >- AstBuilder shall capture each applied metadata annotation nested in an element body as a @@ -173,3 +196,37 @@ sections: tests: - AstBuilder_ExposeBracketFilter_CapturesRawText - AstBuilder_MultipleExposeMembers_OnlyOneBracketed_PairsFilterWithCorrectPath + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-Dependency + title: >- + AstBuilder shall build a SysmlDependencyNode from a standalone `dependency A, B to + C, D;` declaration, splitting the grammar's single flat qualifiedName() list into + FromNames and ToNames by comparing each name's start token index against the TO() + terminal's token index. + justification: | + The `dependency` grammar rule exposes one flat list of qualified names with no + separate "from"/"to" sub-rules, so AstBuilder must positionally split the list itself + before ReferenceResolver can resolve each side independently. Position-based splitting + (rather than looking for the optional FROM keyword) also correctly handles the grammar's + optional FROM keyword being entirely omitted (e.g. "dependency z to x, y;"). + tests: + - WorkspaceLoader_LoadAsync_DependencyBinaryEnds_RecordsDependencyEdge + - WorkspaceLoader_LoadAsync_DependencyCommaLists_RecordsCrossProductEdges + - WorkspaceLoader_LoadAsync_DependencyUnresolvedEnd_ProducesWarningNoEdge + - Dependency_OmgCorpusFixtures_ResolveExpectedEdges + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-BindingConnector + title: >- + AstBuilder shall build a SysmlConnectionNode with ConnectionKeyword "binding" from + the common `bind A = B;` (bindingConnectorAsUsage) grammar shape, reusing the shared + connector-end-reference extraction helper used by `connectionUsage`. + justification: | + Reusing the existing connector-end extraction helper against bindingConnectorAsUsage's + connectorEndMember() entries lets ReferenceResolver treat binding connector endpoints + uniformly with connect/message endpoints (the same dotted-feature-chain walk), without + duplicating endpoint-extraction logic. The longer bindingConnector/typeBody grammar form + has zero corpus evidence and is an intentional, documented non-goal. + tests: + - WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge + - WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge + - Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames diff --git a/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml b/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml index 9af2209c..4897d87a 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml @@ -115,3 +115,38 @@ sections: - WorkspaceLoader_LoadAsync_BareRedefinitionOfInheritedFeature_RecordsRedefinitionEdgeNoWarning - WorkspaceLoader_LoadAsync_RedefinitionExampleFixture_NoUnresolvedReferenceWarnings - WorkspaceLoader_LoadAsync_1cPartsTreeRedefinitionFixture_NoUnresolvedReferenceWarnings + + - id: SysML2Tools-Language-Semantic-Model-ReferenceResolver-DependencyCrossProduct + title: >- + ReferenceResolver.ResolveAll shall independently resolve every entry in a + SysmlDependencyNode's FromNames and ToNames against the symbol table, emitting one + Dependency-kind edge per resolved (from, to) pair (a cross product), and an + unresolved-reference Warning for each name on either side that fails to resolve. + justification: | + The `dependency` grammar allows comma-separated client and supplier lists (e.g. + `dependency a, b to c, d;`), each of which independently participates in the + dependency relationship; per-pair resolution (rather than an all-or-nothing + both-sides contract) ensures a single unresolvable name does not suppress edges for + every other resolvable name, and per-name diagnostics correctly surface exactly which + reference is bogus. + tests: + - WorkspaceLoader_LoadAsync_DependencyBinaryEnds_RecordsDependencyEdge + - WorkspaceLoader_LoadAsync_DependencyCommaLists_RecordsCrossProductEdges + - WorkspaceLoader_LoadAsync_DependencyUnresolvedEnd_ProducesWarningNoEdge + - Dependency_OmgCorpusFixtures_ResolveExpectedEdges + + - id: SysML2Tools-Language-Semantic-Model-ReferenceResolver-BindingFeatureChain + title: >- + ReferenceResolver.ResolveAll shall resolve a "binding"-keyword SysmlConnectionNode's + EndpointA/EndpointB via the same dotted-feature-chain walk used for + "connection"/"message" endpoints, emitting a Binding-kind edge only when both + endpoints resolve. + justification: | + A binding connector's endpoints (`bind A.x = B.y;`) are frequently dotted feature + chains, identical in shape to a connect/message endpoint; reusing the existing + feature-chain walk (rather than a separate resolution path) keeps the two-sided + must-both-resolve contract consistent across every dotted-endpoint edge kind. + tests: + - WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge + - WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge + - Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames 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 cd33a92b..afb3c86b 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 @@ -6,7 +6,7 @@ that construct a synthetic `SysmlWorkspace` of definitions, invoke `BuildLayout`, and assert on the returned `LayoutTree`. A recursive helper collects boxes from the (possibly nested) node tree so assertions can confirm box keywords, folder shapes, compartments, and specialization, membership, -attribute-typing, and redefinition lines. No +attribute-typing, redefinition, subsetting, connect, allocate, dependency, and binding lines. No mocking is required; the strategy depends only on the in-memory model, `LayeredPlacement`, and render options, all constructed directly by the tests. (Phase 2a) Bracket-form `expose` filter-narrowing behavior is delegated to `ExposeScopeResolver` and is verified directly by @@ -29,7 +29,8 @@ configuration are required beyond a standard .NET SDK installation. - 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. - A `port`-feature yields a line with a filled-diamond end marker at the owner end. -- A `ref`-feature yields a line with a hollow-diamond end marker at the owner end. +- A `ref`-feature yields a dashed line with an open-chevron end marker at the referenced type, + sharing the same rendering as a standalone Dependency edge (no hollow-diamond marker). - An `attribute`-feature does NOT yield any diamond end marker edge. - An `attribute`-feature whose type resolves to a definition in the view yields a dashed line with an open-chevron end marker at the attribute-type definition end. @@ -85,6 +86,30 @@ configuration are required beyond a standard .NET SDK installation. - A genuinely self-referential redefinition (a feature's `redefines` target resolves back to its own owning definition, via a self-referential supertype cycle) produces no edge, and layout completes without throwing. +- A resolved `Connect` edge whose endpoints map (via `ResolveOwningBox`) to two distinct rendered + boxes yields a solid line with no end marker between them — including the dominant real-world + shape (two sibling features declared directly in their owning `part def`s, resolved via + `ReferenceResolver`'s instance-path-preserving type-hierarchy fallback), verified end to end + with the real `WorkspaceLoader` and `BuildLayout`, not just a hand-built fixture. +- A resolved `Connect` edge whose endpoints both map to the same rendered box (a genuine self-loop + — e.g. two sibling features of the same enclosing definition with no distinguishing owner) + yields no edge, and the drop is surfaced as a `Connect`-kind warning via + `LayoutWarnings.ForDroppedRelationshipEdges`. +- A resolved `Allocate` edge yields a dashed line with an open-chevron end marker at the target + and a `«allocate»` midpoint label. +- A resolved `Dependency` edge yields a dashed line with an open-chevron end marker at the target + and no midpoint label — the same rendering as the `ref`-fix. +- A resolved `Binding` edge whose endpoints map to two distinct rendered boxes yields a solid line + with no end marker and an `=` midpoint label. +- A subtype feature that subsets a bare-named or qualified inherited feature yields a dashed line + with a hollow-triangle end marker at the owning definition of the subsetted feature. +- A subsetting reference that resolves back to the subtype's own owning definition (a + self-referential same-definition shape) produces no edge. +- Any `Connect`/`Allocate`/`Dependency`/`Binding` edge whose endpoint fails to resolve to a + rendered box, or whose endpoints resolve to the same box, is surfaced as a warning in + `LayoutTree.Warnings` (defense-in-depth diagnostic) — except an unresolved-endpoint drop caused + solely by the endpoint falling outside an active `expose` scope narrowing, which is expected + behavior and produces no warning. ##### Test Scenarios @@ -108,8 +133,8 @@ configuration are required beyond a standard .NET SDK installation. Filled-diamond at owner for `part` feature - `GeneralViewLayoutStrategy_BuildLayout_PortFeature_ProducesFilledDiamondEdge`: Filled-diamond at owner for `port` feature -- `GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesHollowDiamondEdge`: - Hollow-diamond at owner for `ref` feature +- `GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesDependencyEdge`: + Dashed open-chevron Dependency-style edge for `ref` feature (no hollow-diamond marker) - `GeneralViewLayoutStrategy_BuildLayout_AttributeFeature_DoesNotProduceDiamondEdge`: No diamond edge for `attribute` feature - `GeneralViewLayoutStrategy_BuildLayout_AttributeTyping_ProducesDashedOpenChevronEdge`: @@ -164,3 +189,24 @@ configuration are required beyond a standard .NET SDK installation. - `GeneralViewLayoutStrategy_BuildLayout_SelfReferentialRedefinition_ProducesNoEdge`: A genuinely self-referential redefinition (resolving back to its own owning definition via a self-referential supertype cycle) produces no edge and does not throw +- `GeneralViewLayoutStrategy_BuildLayout_Connect_DifferentOwningTypes_ProducesUnmarkedSolidEdge`: + A `Connect` edge between two distinct owning boxes produces a solid line with no end marker +- `GeneralViewLayoutStrategy_BuildLayout_Connect_SameOwningType_ProducesNoEdge`: + A `Connect` edge whose endpoints resolve to the same owning box (self-loop) produces no edge, + and the drop is reported as a `Connect`-kind warning in `LayoutTree.Warnings` +- `GeneralViewLayoutStrategy_BuildLayout_ConnectDominantShape_RealWorkspaceLoader_ProducesDistinctBoxes`: + End-to-end regression guard: the real `WorkspaceLoader` + `BuildLayout` pipeline (no synthetic + edges) renders a `Connect` edge between two distinct boxes for the dominant real-world shape, + with no dropped-edge warning +- `GeneralViewLayoutStrategy_BuildLayout_Allocate_ProducesDashedChevronEdgeWithLabel`: + An `Allocate` edge produces a dashed open-chevron line with a `«allocate»` midpoint label +- `GeneralViewLayoutStrategy_BuildLayout_Dependency_ProducesDashedChevronEdge`: + A `Dependency` edge produces a dashed open-chevron line with no midpoint label +- `GeneralViewLayoutStrategy_BuildLayout_Binding_ProducesSolidEdgeWithEqualsLabel`: + A `Binding` edge produces a solid line with no end marker and an `=` midpoint label +- `GeneralViewLayoutStrategy_BuildLayout_Subsetting_CrossesSpecializationBoundary_ProducesDashedHollowTriangleEdge`: + A `subsets`/`:>` feature reference produces a dashed hollow-triangle edge to the owner of the + subsetted feature +- `GeneralViewLayoutStrategy_BuildLayout_SelfReferentialSubsetting_ProducesNoEdge`: + A self-referential subsetting reference (resolving back to the subtype's own owning definition) + produces no edge 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 e9d33885..7e1fa3b1 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -39,6 +39,20 @@ external services or additional configuration are required beyond a standard .NE 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 feature that declares no redefinition. +- An implicitly-named redefining usage (e.g. `port redefines fuelTankPort { item redefines + fuelSupply; }`, with no explicit declared name) is assigned an `effectiveName`/`QualifiedName` + derived from the redefined feature's simple name, making it resolvable by later references + (including `bind` connector ends) instead of remaining anonymous. When the `redefines` + reference itself is a dot-chained feature path (e.g. `tank.fuelTankPort`), only the trailing + segment is used, not the whole dotted reference text. +- `VisitDependency` splits a `dependency A, B to C, D;` declaration's flat qualified-name list + into `FromNames`/`ToNames` correctly, including the FROM-keyword-omitted shape + (`dependency z to x, y;`), and resolves to the expected cross-product `Dependency` edges (or a + Warning with no edge for an unresolvable name). +- `VisitBindingConnectorAsUsage` captures a `bind A = B;` binding connector's two endpoints on a + `SysmlConnectionNode` with `ConnectionKeyword == "binding"`, resolved via the same + dotted-feature-chain walk as `connect`, producing a `Binding` edge when both sides resolve (or + a Warning with no edge otherwise). ##### Test Scenarios @@ -62,3 +76,12 @@ external services or additional configuration are required beyond a standard .NE | Redefinition capture, `:>>` operator | `WorkspaceLoader_LoadAsync_ColonGtGtOperator_CapturesRedefinedFeatureName` | | Redefinition capture, qualified form | `WorkspaceLoader_LoadAsync_QualifiedRedefinition_CapturesRawText` | | No redefinition leaves field null | `WorkspaceLoader_LoadAsync_NoRedefinition_RedefinedFeatureNameIsNull` | +| Implicit redefinition name | `WorkspaceLoader_LoadAsync_BindingViaImplicitlyNamedRedefinedUsage_RecordsBindingEdge` | +| Dotted redefinition name | `WorkspaceLoader_LoadAsync_ImplicitNameFromDottedRedefinitionChain_UsesTrailingSegment` | +| Dependency binary ends | `WorkspaceLoader_LoadAsync_DependencyBinaryEnds_RecordsDependencyEdge` | +| Dependency comma-list cross product | `WorkspaceLoader_LoadAsync_DependencyCommaLists_RecordsCrossProductEdges` | +| Dependency unresolved end | `WorkspaceLoader_LoadAsync_DependencyUnresolvedEnd_ProducesWarningNoEdge` | +| Dependency OMG corpus fixtures | `Dependency_OmgCorpusFixtures_ResolveExpectedEdges` | +| Binding dotted-chain resolution | `WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge` | +| Binding unresolved end | `WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge` | +| Binding OMG corpus fixture | `Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames` | diff --git a/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md b/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md index 76ea99d1..2cdc59b8 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md +++ b/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md @@ -36,6 +36,23 @@ external services or additional configuration are required beyond a standard .NE - A resolved feature redefinition reference (`redefines X;` / `:>> X`) is recorded as a `Redefinition`-kind `SysmlEdge`; an unresolved one produces a Warning diagnostic naming the unresolved identifier and no edge, mirroring `FeatureTyping`'s resolution behavior exactly. +- A standalone `dependency A, B to C, D;` declaration resolves each `FromNames`/`ToNames` entry + independently and emits one `Dependency`-kind `SysmlEdge` per resolved (from, to) pair (a + cross product); an unresolvable name on either side produces its own Warning diagnostic + without suppressing edges for the other resolvable names. +- A `bind A = B;` binding connector's endpoints resolve via the same dotted-feature-chain walk + used for `connect`/`message`, recording a `Binding`-kind `SysmlEdge` only when both endpoints + resolve. +- A dotted feature-chain endpoint (e.g. `engine.fuelPort`, `rearAxle.leftHalfAxle.axleToWheelPort`) + resolves segment-by-segment via `TryResolveFeatureChain`/`FindFeatureMember`, producing an + instance-relative qualified name for the final segment regardless of whether each segment + resolved via a direct-child match or the type-hierarchy fallback branch: for the dominant + real-world shape — two sibling features declared directly in their owning `part def`s, + referenced from an enclosing part via bare usages with no per-instance nested redeclaration — + every segment resolves via the fallback branch, and the returned qualified name is still + instance-relative (e.g. `Drone::controller::power`, not the shared port type's own declared + path), so that two structurally distinct endpoints of the same type never collapse to the same + resolved name. ##### Test Scenarios @@ -60,3 +77,19 @@ external services or additional configuration are required beyond a standard .NE | Cross-file | `WorkspaceLoader_LoadAsync_CrossFileOutOfOrderRedefinitionChain_RecordsRedefinitionEdgeNoWarning` | | OMG `RedefinitionExample` | `WorkspaceLoader_LoadAsync_RedefinitionExampleFixture_NoUnresolvedReferenceWarnings` | | OMG PartsTree fixture | `WorkspaceLoader_LoadAsync_1cPartsTreeRedefinitionFixture_NoUnresolvedReferenceWarnings` | +| Dependency binary ends | `WorkspaceLoader_LoadAsync_DependencyBinaryEnds_RecordsDependencyEdge` | +| Dependency comma-list cross product | `WorkspaceLoader_LoadAsync_DependencyCommaLists_RecordsCrossProductEdges` | +| Dependency unresolved end | `WorkspaceLoader_LoadAsync_DependencyUnresolvedEnd_ProducesWarningNoEdge` | +| Dependency OMG corpus fixtures | `Dependency_OmgCorpusFixtures_ResolveExpectedEdges` | +| Binding dotted-chain resolution | `WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge` | +| Binding unresolved end | `WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge` | +| Binding OMG corpus fixture | `Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames` | +| Connect single-segment endpoints | `WorkspaceLoader_LoadAsync_ConnectionSingleSegmentEndpoints_RecordsConnectEdge` | +| Direct-child chain | `WorkspaceLoader_LoadAsync_ConnectionTwoSegmentChain_ResolvesViaDirectChild` | +| Fallback chain, instance path | `WorkspaceLoader_LoadAsync_ConnectionTwoSegmentChain_ResolvesViaTypingFallback` | +| Mixed chain branches | `WorkspaceLoader_LoadAsync_ConnectionThreeSegmentChain_MixesDirectChildAndTypingFallback` | +| Chain via inherited feature | `WorkspaceLoader_LoadAsync_ConnectionChain_ResolvesInheritedFeatureViaSupertype` | +| Dominant shape, distinct paths | `WorkspaceLoader_LoadAsync_ConnectionDominantShape_ResolvesDistinctInstancePaths` | +| Unresolved chain endpoint | `WorkspaceLoader_LoadAsync_ConnectionUnresolvedEndpoint_ProducesWarningNoEdge` | +| Supertype-cycle chain segment | `WorkspaceLoader_LoadAsync_ConnectionChain_SupertypeCycleTerminatesGracefully` | +| OMG Connections example fixture | `WorkspaceLoader_LoadAsync_ConnectionsExampleFixture_RecordsConnectEdge` | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 5d939ab4..6d006d42 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -43,11 +43,17 @@ internal sealed class GeneralViewLayoutStrategy : ILayoutStrategy private const double CharWidthFactor = 0.62; /// - /// A feature membership: the keyword, raw typing reference (if any), simple name, and raw - /// redefined-feature reference (if any) of one owned feature. is - /// nullable because a feature may declare a redefinition without an explicit type annotation. + /// A feature membership: the keyword, raw typing reference (if any), simple name, raw + /// redefined-feature reference (if any), and raw subsetted-feature reference(s) (if any) of + /// one owned feature. is nullable because a feature may declare a + /// redefinition/subsetting without an explicit type annotation. /// - private sealed record FeatureMembership(string Keyword, string? TypeName, string? Name, string? RedefinedFeatureName); + private sealed record FeatureMembership( + string Keyword, + string? TypeName, + string? Name, + string? RedefinedFeatureName, + IReadOnlyList SubsettedFeatureNames); /// /// The classification of an edge, which selects its line style so the renderer can distinguish @@ -73,6 +79,41 @@ private enum EdgeKind /// line, hollow-triangle-with-crossbar at the owner. /// Redefinition, + + /// + /// Subtype feature subsetting → the owning definition of the subsetted (ancestor) feature: + /// dashed line, hollow triangle at the owner (the same marker as + /// , distinguished purely by line style, mirroring how + /// is distinguished from ). + /// + Subsetting, + + /// + /// A resolved connect A to B/message reference between two definitions (each + /// endpoint's owning box resolved via ): solid line, no + /// end marker. + /// + Connect, + + /// + /// A resolved allocate A to B reference: dashed line, open chevron at the target, + /// with a «allocate» midpoint label. + /// + Allocate, + + /// + /// A resolved standalone dependency reference, and the ref-keyword's + /// feature-typing dependency (sharing this same rendering so both are visually + /// identical): dashed line, open chevron at the depended-upon element. + /// + Dependency, + + /// + /// A resolved bind A = B binding connector reference (each endpoint's owning box + /// resolved via ): solid line, no end marker, with a + /// = midpoint label distinguishing it from . + /// + Binding, } /// @@ -83,11 +124,22 @@ private enum EdgeKind /// Qualified name of the target (supertype, owner, or attribute-type) definition. /// Arrowhead drawn at the target end. /// The edge classification, which selects the rendered line style. - private sealed record ModelEdge(string SourceQualified, string TargetQualified, EndMarkerStyle Arrowhead, EdgeKind Kind); - - /// Maps an edge kind to its rendered line style: typing edges are dashed, all others solid. + /// + /// Optional midpoint label (e.g. «allocate», =), or for + /// the majority of kinds that render no label. + /// + private sealed record ModelEdge( + string SourceQualified, + string TargetQualified, + EndMarkerStyle Arrowhead, + EdgeKind Kind, + string? Label = null); + + /// Maps an edge kind to its rendered line style: typing/allocate/dependency/subsetting edges are dashed, all others solid. private static LineStyle LineStyleForKind(EdgeKind kind) => - kind == EdgeKind.Typing ? LineStyle.Dashed : LineStyle.Solid; + kind is EdgeKind.Typing or EdgeKind.Allocate or EdgeKind.Dependency or EdgeKind.Subsetting + ? LineStyle.Dashed + : LineStyle.Solid; /// A user-defined definition together with its computed box size and supertypes. private sealed record DefBox( @@ -111,6 +163,20 @@ private sealed record DefBox( /// Number of hidden definitions the ellipsis label reports. private sealed record TruncatedFolder(LayoutGraphNode Node, int HiddenCount); + /// + /// A // + /// / edge from + /// workspace.Index.AllEdges that was not rendered because an endpoint failed to resolve + /// to a rendered box, or because both endpoints resolved to the same box (a genuine self-loop). + /// Reported via so the drop is visible + /// instead of silent. + /// + /// The short edge-kind label (e.g. "Connect"). + /// The edge's raw source qualified/dotted-chain-resolved reference. + /// The edge's raw target qualified/dotted-chain-resolved reference. + /// A short human-readable explanation of why the edge was dropped. + private sealed record DroppedRelationshipEdge(string Kind, string Source, string Target, string Reason); + /// public LayoutTree BuildLayout(ViewContext context, RenderOptions options) { @@ -168,7 +234,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) // 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 // node (because its folder was depth-truncated). - var modelEdges = BuildModelEdges(defs); + 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); @@ -192,6 +258,9 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) // warning — through the standard layout-warnings channel. var warnings = LayoutWarnings.ForUnevaluatedFilter(context.ViewName, filterFailureReason is null ? null : filterExpressionText, filterFailureReason) .Concat(LayoutWarnings.ForUnevaluatedExposeBracketFilter(context.ViewName, scope?.Failures ?? [])) + .Concat(LayoutWarnings.ForDroppedRelationshipEdges(context.ViewName, droppedEdges + .Select(d => (d.Kind, d.Source, d.Target, d.Reason)) + .ToList())) .ToList(); return warnings.Count == 0 ? placed : placed with { Warnings = warnings }; } @@ -289,8 +358,8 @@ private static string FormatFeatureRow(SysmlFeatureNode feature) /// /// Collects the feature memberships of a definition: the keyword, type reference (if any), simple - /// name, and redefined-feature reference (if any) of each owned feature that carries a type - /// annotation and/or a redefinition. + /// name, redefined-feature reference (if any), and subsetted-feature reference(s) (if any) of + /// each owned feature that carries a type annotation, a redefinition, and/or a subsetting. /// private static IReadOnlyList CollectMemberships(SysmlDefinitionNode def) { @@ -303,10 +372,10 @@ private static IReadOnlyList CollectMemberships(SysmlDefiniti } var typing = feature.FeatureTyping is { Length: > 0 } ft ? ft : null; - if (typing is not null || feature.RedefinedFeatureName is not null) + if (typing is not null || feature.RedefinedFeatureName is not null || feature.SupertypeNames.Count > 0) { var keyword = string.IsNullOrEmpty(feature.FeatureKeyword) ? "feature" : feature.FeatureKeyword; - result.Add(new FeatureMembership(keyword, typing, feature.Name, feature.RedefinedFeatureName)); + result.Add(new FeatureMembership(keyword, typing, feature.Name, feature.RedefinedFeatureName, feature.SupertypeNames)); } } @@ -395,12 +464,26 @@ private static double ComputeCompartmentHeight(LayoutCompartment compartment, Th } /// - /// Resolves the specialization, membership, and attribute-typing relationships across every - /// definition (regardless of package) into a flat list of qualified-name edges. Self-references - /// and unresolved targets are skipped; whether an edge's endpoints actually receive a graph node - /// (i.e., were not depth-truncated) is decided later, in . + /// Resolves the specialization, membership, attribute-typing, redefinition, subsetting, + /// connect, allocate, dependency, and binding relationships across every definition (regardless + /// of package) into a flat list of qualified-name edges. Self-references and unresolved targets + /// are skipped; whether an edge's endpoints actually receive a graph node (i.e., were not + /// depth-truncated) is decided later, in . The second tuple element + /// reports every // + /// / edge from + /// workspace.Index.AllEdges that was dropped because an endpoint failed to resolve to a + /// rendered box, or because both endpoints resolved to the same box (a genuine self-loop) — for + /// surfacing via , so a residual/future + /// collapse is visible instead of silently dropped. An unresolved-endpoint drop is only + /// reported when is (the view renders the + /// whole workspace, so an endpoint that fails to resolve to a box is genuinely wrong); when the + /// view is narrowed by expose, an endpoint outside the exposed scope legitimately fails + /// to resolve to a box by design and is not reported, to avoid misleading noise for ordinary, + /// intentional scope narrowing. A genuine self-loop (both endpoints resolve, to the same box) + /// is always reported, regardless of scoping, since it can never be an intended outcome. /// - private static List BuildModelEdges(IReadOnlyList defs) + private static (List Edges, List Dropped) BuildModelEdges( + IReadOnlyList defs, SysmlWorkspace workspace, bool isScoped) { // Index every definition by qualified and simple name (first-seen wins for simple names, // mirroring how packages may reuse a simple name across different qualified locations). @@ -427,13 +510,14 @@ private static List BuildModelEdges(IReadOnlyList defs) } // Membership: member-type → owner, diamond at the owner (target) end. Structural keywords - // (part/port) use a filled diamond; reference (ref) uses a hollow diamond; others emit none. + // (part/port) use a filled diamond; others emit none. (The "ref" keyword previously used + // an obsolete hollow-diamond marker here — it has moved to its own Dependency-shaped edge + // below, per current OMG SysML v2 notation for reference usages.) foreach (var membership in def.Memberships) { var arrowhead = membership.Keyword switch { "part" or "port" => EndMarkerStyle.FilledDiamond, - "ref" => EndMarkerStyle.HollowDiamond, _ => EndMarkerStyle.None, }; @@ -465,6 +549,25 @@ private static List BuildModelEdges(IReadOnlyList defs) } } + // ref (reference usage) typing: owner → referenced-type dependency, sharing the same + // Dependency rendering (dashed, open chevron) as the new public Dependency edge kind + // below, per current OMG SysML v2 notation for reference usages (no longer an obsolete + // hollow-diamond membership marker). + foreach (var membership in def.Memberships) + { + if (membership.Keyword != "ref") + { + continue; + } + + if (membership.TypeName is { Length: > 0 } refTypeName && + TryResolveQualified(refTypeName, byQualified, bySimple, out var refType) && + refType != def.QualifiedName) + { + edges.Add(new ModelEdge(def.QualifiedName, refType, EndMarkerStyle.OpenChevron, EdgeKind.Dependency)); + } + } + // Redefinition: subtype → the owning definition of the redefined feature, hollow // triangle with crossbar at the owner (target) end. A qualified reference // (Owner::feature) resolves the owner directly; a bare reference is looked up by @@ -482,9 +585,192 @@ private static List BuildModelEdges(IReadOnlyList defs) edges.Add(new ModelEdge(def.QualifiedName, owner, EndMarkerStyle.HollowTriangleCrossbar, EdgeKind.Redefinition)); } } + + // Subsetting: subtype → the owning definition of each subsetted (ancestor) feature, + // hollow triangle (dashed) at the owner (target) end. Reuses ResolveRedefinitionOwner + // verbatim — a subsetted-feature reference is resolved identically to a + // redefined-feature reference (qualified reference resolves the owner directly; a bare + // reference walks the definition's own supertype chain for a matching member name). + foreach (var membership in def.Memberships) + { + foreach (var subsettedRef in membership.SubsettedFeatureNames) + { + var owner = ResolveRedefinitionOwner(def, subsettedRef, byQualified, bySimple, defByQualified); + if (owner is not null && owner != def.QualifiedName) + { + edges.Add(new ModelEdge(def.QualifiedName, owner, EndMarkerStyle.HollowTriangle, EdgeKind.Subsetting)); + } + } + } + } + + // Connect/Allocate/Dependency/Binding: resolved directly from the workspace's semantic + // index (already computed by ReferenceResolver — no re-resolution needed here), each + // endpoint mapped to its owning rendered box via ResolveOwningBox. An edge is only emitted + // when both endpoints resolve to distinct boxes; a same-box result (e.g. two sibling + // features of the same enclosing definition) is a genuine self-loop and is dropped, exactly + // as every other edge kind in this method already does. Every dropped edge is also + // considered for `dropped` (LayoutWarnings.ForDroppedRelationshipEdges) — see RecordDrop. + var dropped = new List(); + + void RecordDrop(string kind, string source0, string target0, string? source, string? target) + { + // A genuine self-loop (both endpoints resolved, to the same box) is always reported. + // An unresolved endpoint is only reported when the view is not `expose`-scoped: within + // a scoped view, an endpoint outside the exposed subtree legitimately fails to resolve + // to a box by design, and warning about it would be misleading noise for ordinary, + // intentional scope narrowing. + if ((source is not null && target is not null) || !isScoped) + { + dropped.Add(new DroppedRelationshipEdge(kind, source0, target0, DropReason(source, target))); + } + } + + foreach (var edge in workspace.Index.AllEdges) + { + if (edge.SourceQualifiedName is not { Length: > 0 } sourceRef) + { + continue; + } + + switch (edge.Kind) + { + case SysmlEdgeKind.Connect: + { + var source = ResolveOwningBox(sourceRef, workspace, byQualified, bySimple); + var target = ResolveOwningBox(edge.TargetQualifiedName, workspace, byQualified, bySimple); + if (source is not null && target is not null && source != target) + { + edges.Add(new ModelEdge(source, target, EndMarkerStyle.None, EdgeKind.Connect)); + } + else + { + RecordDrop("Connect", sourceRef, edge.TargetQualifiedName, source, target); + } + + break; + } + + case SysmlEdgeKind.Allocate: + { + var source = ResolveOwningBox(sourceRef, workspace, byQualified, bySimple); + var target = ResolveOwningBox(edge.TargetQualifiedName, workspace, byQualified, bySimple); + if (source is not null && target is not null && source != target) + { + edges.Add(new ModelEdge(source, target, EndMarkerStyle.OpenChevron, EdgeKind.Allocate, "«allocate»")); + } + else + { + RecordDrop("Allocate", sourceRef, edge.TargetQualifiedName, source, target); + } + + break; + } + + case SysmlEdgeKind.Dependency: + { + var source = ResolveOwningBox(sourceRef, workspace, byQualified, bySimple); + var target = ResolveOwningBox(edge.TargetQualifiedName, workspace, byQualified, bySimple); + if (source is not null && target is not null && source != target) + { + edges.Add(new ModelEdge(source, target, EndMarkerStyle.OpenChevron, EdgeKind.Dependency)); + } + else + { + RecordDrop("Dependency", sourceRef, edge.TargetQualifiedName, source, target); + } + + break; + } + + case SysmlEdgeKind.Binding: + { + var source = ResolveOwningBox(sourceRef, workspace, byQualified, bySimple); + var target = ResolveOwningBox(edge.TargetQualifiedName, workspace, byQualified, bySimple); + if (source is not null && target is not null && source != target) + { + edges.Add(new ModelEdge(source, target, EndMarkerStyle.None, EdgeKind.Binding, "=")); + } + else + { + RecordDrop("Binding", sourceRef, edge.TargetQualifiedName, source, target); + } + + break; + } + } + } + + return (edges, dropped); + } + + /// + /// Returns a short human-readable reason a Connect/Allocate/Dependency/Binding edge's endpoints + /// did not resolve to two distinct rendered boxes, for + /// . + /// + private static string DropReason(string? source, string? target) + { + if (source is null && target is null) + { + return "neither endpoint resolved to a box"; + } + + if (source is null) + { + return "source endpoint did not resolve to a box"; + } + + if (target is null) + { + return "target endpoint did not resolve to a box"; + } + + return "both endpoints resolved to the same box"; + } + + /// + /// Resolves the qualified name of the rendered box that "owns" the given endpoint reference, + /// for / endpoint mapping. A + /// reference that already names a definition resolves directly (the common case for + /// definition-to-definition references, e.g. / + /// ); otherwise this walks successively shorter "::" + /// prefixes of the (dotted-chain-resolved) qualified name, from longest to shortest, looking + /// for a whose resolved edge + /// targets a rendered box; the shortest matching prefix wins (the feature immediately + /// owned by the rendered enclosing definition), which prevents the self-loop a naive + /// "nearest enclosing definition" walk would produce for the dominant real-world shape where + /// both sides of a connect/bind are nested inside the very same definition (e.g. + /// connect controller.power to battery.output; inside Drone — walking to the + /// nearest enclosing definition would resolve both sides to Drone itself). + /// + private static string? ResolveOwningBox( + string qualifiedName, + SysmlWorkspace workspace, + HashSet byQualified, + Dictionary bySimple) + { + if (TryResolveQualified(qualifiedName, byQualified, bySimple, out var direct)) + { + return direct; + } + + var segments = qualifiedName.Split("::"); + string? candidate = null; + for (var length = segments.Length; length >= 1; length--) + { + var prefix = string.Join("::", segments.Take(length)); + if (workspace.Declarations.TryGetValue(prefix, out var declaration) && + declaration is SysmlFeatureNode && + declaration.ResolvedEdges.FirstOrDefault(e => e.Kind == SysmlEdgeKind.Typing) is { } typingEdge && + byQualified.Contains(typingEdge.TargetQualifiedName)) + { + // Keep walking to shorter prefixes: the shortest (outermost) matching prefix wins. + candidate = typingEdge.TargetQualifiedName; + } } - return edges; + return candidate; } /// @@ -684,6 +970,7 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( var added = scope.AddEdge($"e{edgeId++}", source.Node, target.Node); added.TargetEnd = edge.Arrowhead; added.LineStyle = LineStyleForKind(edge.Kind); + added.Label = edge.Label; } return (graph, truncated); diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs index e10c2cb2..b343bc69 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs @@ -99,4 +99,34 @@ public static IReadOnlyList ForUnevaluatedExposeBracketFilter( }) .ToList(); } + + /// + /// Returns one warning message per Connect/Allocate/Dependency/ + /// Binding relationship edge that could not be rendered because an endpoint failed to + /// resolve to a rendered box, or because both endpoints resolved to the same box (a genuine + /// self-loop), or an empty list when every such edge in the view rendered normally. This is a + /// defense-in-depth diagnostic: a correctly-resolving model never produces any of these + /// warnings, but a genuinely unresolvable or self-referential relationship is now surfaced + /// instead of silently dropped. + /// + /// Name of the view being laid out. + /// + /// Each dropped edge's kind (e.g. "Connect"), raw source/target references, and a short + /// human-readable reason it was not rendered. + /// + /// The warning messages for the view. + public static IReadOnlyList ForDroppedRelationshipEdges( + string viewName, IReadOnlyList<(string Kind, string Source, string Target, string Reason)> dropped) + { + if (dropped.Count == 0) + { + return []; + } + + return dropped + .Select(edge => + $"{edge.Kind} edge '{edge.Source}' -> '{edge.Target}' in '{viewName}' was not " + + $"rendered ({edge.Reason}).") + .ToList(); + } } diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index 6b4734f4..3e73ef8b 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -574,6 +574,26 @@ private sealed class AnnotationCapture : SysmlNode }; } + /// + public override SysmlNode? VisitBindingConnectorAsUsage(SysMLv2Parser.BindingConnectorAsUsageContext context) + { + // Only the common "bind A = B;" (bindingConnectorAsUsage) shape is supported; the longer + // bindingConnector/typeBody form has zero corpus evidence and is a documented limitation. + var name = GetDeclaredName(context.usageDeclaration()?.identification()); + var ends = context.connectorEndMember(); + var endpointA = ends.Length > 0 ? ConnectorEndReference(ends[0]) : null; + var endpointB = ends.Length > 1 ? ConnectorEndReference(ends[1]) : null; + + return new SysmlConnectionNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + ConnectionKeyword = "binding", + EndpointA = endpointA, + EndpointB = endpointB, + }; + } + /// public override SysmlNode? VisitSatisfyRequirementUsage(SysMLv2Parser.SatisfyRequirementUsageContext context) { @@ -592,6 +612,29 @@ private sealed class AnnotationCapture : SysmlNode }; } + /// + public override SysmlNode? VisitDependency(SysMLv2Parser.DependencyContext context) + { + // Split the flat qualifiedName() list into from/to by comparing each name's start token + // index against TO()'s token index: everything before TO is a "from" (client) name, + // everything after is a "to" (supplier) name. The optional FROM keyword may be omitted + // (e.g. "dependency z to x, y;"), in which case the single qualifiedName captured before + // TO is still correctly classified as the (implicit) "from" name by this position check. + var toTokenIndex = context.TO()?.Symbol.TokenIndex ?? int.MaxValue; + var fromNames = new List(); + var toNames = new List(); + foreach (var qualifiedName in context.qualifiedName()) + { + (qualifiedName.Start.TokenIndex < toTokenIndex ? fromNames : toNames).Add(qualifiedName.GetText()); + } + + return new SysmlDependencyNode + { + FromNames = fromNames, + ToNames = toNames, + }; + } + /// public override SysmlNode? VisitRequirementUsage(SysMLv2Parser.RequirementUsageContext context) { @@ -769,21 +812,30 @@ private static (string? A, string? B) ExtractConnectorEnds(SysMLv2Parser.Connect var supertypeNames = ExtractSubsettingTargetNames(decl?.featureSpecializationPart()); var multiplicity = ExtractMultiplicity(decl?.featureSpecializationPart()); + // An unnamed usage that redefines a feature (e.g. `port redefines fuelTankPort { ... }`, + // with no name token of its own) implicitly takes the redefined feature's own simple name + // per SysML v2 semantics — without this fallback, such a usage's Name/QualifiedName stay + // null and it can never be referenced or resolved by name (e.g. as a `connect`/`bind` + // endpoint), even though the model clearly identifies it. Only the trailing segment of the + // (possibly qualified) redefined reference is used, mirroring how a redefined feature's own + // declared name is always just its simple name. + var effectiveName = name ?? (redefined is not null ? SimpleNameFromReference(redefined) : null); + // Named usages contribute a namespace segment for any nested usages they own. - var qualifiedName = name is not null ? QualifyName(name) : null; + var qualifiedName = effectiveName is not null ? QualifyName(effectiveName) : null; IReadOnlyList children = Array.Empty(); IReadOnlyList annotations = Array.Empty(); var body = usage.usageCompletion()?.usageBody()?.definitionBody(); if (body is not null) { - if (name is not null) + if (effectiveName is not null) { - _namespaceStack.Add(name); + _namespaceStack.Add(effectiveName); } (children, annotations) = CollectDefinitionBodyItems(body.definitionBodyItem()); - if (name is not null) + if (effectiveName is not null) { _namespaceStack.RemoveAt(_namespaceStack.Count - 1); } @@ -791,7 +843,7 @@ private static (string? A, string? B) ExtractConnectorEnds(SysMLv2Parser.Connect return new SysmlFeatureNode { - Name = name, + Name = effectiveName, QualifiedName = qualifiedName, FeatureKeyword = keyword, FeatureTyping = typing, @@ -897,6 +949,29 @@ private static (string? A, string? B) ExtractConnectorEnds(SysMLv2Parser.Connect return null; } + /// + /// Derives the trailing simple-name segment from a raw (possibly qualified and/or + /// dot-chained) reference text, e.g. "Owner::fuelTankPort""fuelTankPort", + /// and "tank.fuelTankPort""fuelTankPort" (an ownedRedefinition is + /// grammatically a qualifiedName ( DOT qualifiedName )* chain, so a redefinition + /// reference can be a dotted feature path, not just a single ::-qualified name). Takes + /// whichever of the last :: or last . separator occurs furthest to the right, so + /// a reference with neither separator is returned unchanged. Used to derive an unnamed usage's + /// implicit name from the feature it redefines (see the effectiveName fallback in + /// ). + /// + private static string SimpleNameFromReference(string reference) + { + var afterColonColon = reference.LastIndexOf("::", StringComparison.Ordinal) is var colonIndex && colonIndex >= 0 + ? colonIndex + 2 + : 0; + var afterDot = reference.LastIndexOf('.') is var dotIndex && dotIndex >= 0 + ? dotIndex + 1 + : 0; + var start = Math.Max(afterColonColon, afterDot); + return start > 0 ? reference[start..] : reference; + } + /// /// Extracts the raw reference text of every subsets <target>;/:> /// <target> clause on a usage/feature — a subsetting reference, grammatically diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs index dad5bfa7..8be14370 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs @@ -744,6 +744,57 @@ private void ResolveNode( } } + // Standalone dependency statements (SysmlDependencyNode) resolve every "from" name against + // every "to" name independently (a cross product, per the grammar's list-of-lists shape), + // mirroring Satisfy/Allocate's single-segment TryResolve resolution. An unresolved name + // produces one Warning diagnostic per distinct unresolved name per file (via + // resolvedInFile, the same de-duplication used by every other block in this method); a + // pair is only emitted as an edge when both its from-name and to-name resolve. + if (node is SysmlDependencyNode dependency) + { + var resolvedFrom = new List(); + foreach (var fromName in dependency.FromNames) + { + if (TryResolve(fromName, namespaceStack, imports, out var resolved)) + { + resolvedFrom.Add(resolved); + } + else if (resolvedInFile.Add(fromName)) + { + _diagnostics.Add(new SysmlDiagnostic( + filePath, + 0, 0, + DiagnosticSeverity.Warning, + $"Unresolved reference: '{fromName}'")); + } + } + + var resolvedTo = new List(); + foreach (var toName in dependency.ToNames) + { + if (TryResolve(toName, namespaceStack, imports, out var resolved)) + { + resolvedTo.Add(resolved); + } + else if (resolvedInFile.Add(toName)) + { + _diagnostics.Add(new SysmlDiagnostic( + filePath, + 0, 0, + DiagnosticSeverity.Warning, + $"Unresolved reference: '{toName}'")); + } + } + + foreach (var from in resolvedFrom) + { + foreach (var to in resolvedTo) + { + nodeEdges.Add(new SysmlEdge(from, to, SysmlEdgeKind.Dependency)); + } + } + } + // 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 @@ -875,6 +926,22 @@ private void ResolveFeatureChains( } } + // Binding connector endpoints ("bind A = B;", the bindingConnectorAsUsage shape only) + // resolve via the same dotted feature-chain walk as connection/message, for parity per + // the corpus's frequent dotted-chain bind sides (e.g. "bind engine.fuelCmdPort=fuelCmdPort;"). + if (node is SysmlConnectionNode { ConnectionKeyword: "binding" } binding) + { + var resolvedA = ResolveFeatureChainSide( + binding.EndpointA, filePath, resolvedInFile, namespaceStack, imports); + var resolvedB = ResolveFeatureChainSide( + binding.EndpointB, filePath, resolvedInFile, namespaceStack, imports); + + if (resolvedA is not null && resolvedB is not null) + { + nodeEdges.Add(new SysmlEdge(resolvedA, resolvedB, SysmlEdgeKind.Binding)); + } + } + // Transition source/target (an implied/omitted Source produces no edge — documented // limitation, since there is nothing to walk a chain from). if (node is SysmlTransitionNode transition) @@ -954,13 +1021,27 @@ private void ResolveFeatureChains( /// /// Resolves a dotted feature chain (e.g. engine.fuelPort, - /// rearAxle.leftHalfAxle.axleToWheelPort) to the qualified name of its final - /// segment. Segment 0 is resolved via the existing four-step - /// lookup (so it participates in the same scope/import resolution as any other single-name - /// reference); each subsequent segment is resolved relative to the previous segment's - /// node via . A single-segment "chain" (no .) is - /// handled by the loop simply never executing, so this method also serves as the - /// single-segment resolver used elsewhere. + /// rearAxle.leftHalfAxle.axleToWheelPort) to an instance-relative qualified path for + /// its final segment. Segment 0 is resolved via the existing + /// four-step lookup (so it participates in the same scope/import resolution as any other + /// single-name reference); each subsequent segment is resolved relative to the previous + /// segment's node via . A single-segment "chain" (no + /// .) is handled by the loop simply never executing, so this method also serves as + /// the single-segment resolver used elsewhere. + /// A parallel instancePath accumulator is tracked alongside the real declared node's + /// qualified name (current, which continues to drive the next segment's + /// _symbolTable.Lookup): while every segment so far has resolved via + /// 's direct-child branch, instancePath is simply the + /// member's own (already instance-relative) qualified name. Once any segment resolves via + /// the type-hierarchy fallback branch, current stops referring to a real instance + /// node (it becomes the type's own declared path), so every remaining segment — even one + /// that is itself a "direct child" of that type-declared node — is also only reachable + /// relative to the type, not the instance; the accumulator therefore latches into + /// instance-relative-append mode ({previous instancePath}::{segment}) as soon as the + /// first fallback hop occurs, and stays in that mode for every subsequent segment, + /// preserving the instance-relative path rather than collapsing back to a type-declared path + /// on a later direct-child hop. This is what ultimately + /// returns. /// /// The raw, possibly dotted, reference text. /// @@ -968,8 +1049,8 @@ private void ResolveFeatureChains( /// /// All import nodes collected from the current file. /// - /// When this method returns , the qualified name of the chain's - /// final segment. When this method returns , set to + /// When this method returns , the instance-relative qualified path of + /// the chain's final segment. When this method returns , set to /// . /// /// @@ -990,6 +1071,18 @@ private bool TryResolveFeatureChain( return false; } + var instancePath = current; + + // Once any segment resolves via the type-hierarchy fallback branch, `current` no longer + // refers to a real instance node — it is the type's own declared path. Every subsequent + // segment is therefore looked up *within that type's declared structure*, so even a + // "direct child" match at that point yields a type-declared (not instance-relative) + // QualifiedName. `inTypeContext` latches once fallback is hit and stays set, so the + // instance-relative accumulation keeps applying to every later segment too — fixing a bug + // where a direct-child hop occurring *after* an earlier fallback hop would incorrectly + // reset `instancePath` back to the type's own declared path. + var inTypeContext = false; + for (var i = 1; i < segments.Length; i++) { var currentNode = _symbolTable.Lookup(current); @@ -999,7 +1092,7 @@ private bool TryResolveFeatureChain( return false; } - var member = FindFeatureMember(currentNode, segments[i]); + var member = FindFeatureMember(currentNode, segments[i], out var viaTypeFallback); if (member?.QualifiedName is not { Length: > 0 } memberQualifiedName) { resolvedName = string.Empty; @@ -1007,9 +1100,11 @@ private bool TryResolveFeatureChain( } current = memberQualifiedName; + inTypeContext = inTypeContext || viaTypeFallback; + instancePath = inTypeContext ? $"{instancePath}::{segments[i]}" : memberQualifiedName; } - resolvedName = current; + resolvedName = instancePath; return true; } @@ -1022,14 +1117,27 @@ private bool TryResolveFeatureChain( /// target's own hierarchy (direct children and supertype chain) when no direct child /// matches. /// - private SysmlNode? FindFeatureMember(SysmlNode node, string name) + /// The node whose members are searched. + /// The member name being looked up. + /// + /// Set to when the returned node was found among + /// 's own direct children (the result's QualifiedName is + /// already instance-relative). Set to when the returned node was + /// found via the target's own hierarchy (the result's + /// QualifiedName is the type's own declared path, not instance-relative — callers + /// that need an instance-relative path must reconstruct it themselves). + /// + private SysmlNode? FindFeatureMember(SysmlNode node, string name, out bool viaTypeFallback) { var direct = node.Children.FirstOrDefault(c => c.Name == name); if (direct is not null) { + viaTypeFallback = false; return direct; } + viaTypeFallback = true; + var typingEdge = node.ResolvedEdges.FirstOrDefault(e => e.Kind == SysmlEdgeKind.Typing); if (typingEdge is null) { diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs index 508e44b1..868c9ce4 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlEdge.cs @@ -91,6 +91,27 @@ public enum SysmlEdgeKind /// def declaration it references. /// MetadataType, + + /// + /// A standalone dependency reference (dependency (id)? (from A(,A)*)? to B(,B)*;, + /// / ), + /// recorded as one edge per resolvable from×to pair, Source=A, Target=B, reflecting + /// the client (from) depending on the supplier (to). Only the common + /// single-segment name form is resolved (parity with / + /// ); an unresolved name produces a diagnostic instead of an edge. + /// + Dependency, + + /// + /// A binding connector reference (bind A = B;, the common + /// bindingConnectorAsUsage shape only), recorded as a single edge + /// Source=A, Target=B reflecting the textual left-to-right order of the binding. + /// Either endpoint may be a dotted feature chain, resolved by ReferenceResolver's + /// feature-chain walk (parity with ); the longer + /// bindingConnector/typeBody form is a documented, out-of-scope limitation + /// (no corpus evidence). + /// + Binding, } /// diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs index 830c91fc..91691f39 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs @@ -32,6 +32,7 @@ namespace DemaConsulting.SysML2Tools.Semantic.Model; [JsonDerivedType(typeof(SysmlTransitionNode), "transition")] [JsonDerivedType(typeof(SysmlSatisfyNode), "satisfy")] [JsonDerivedType(typeof(SysmlMetadataNode), "metadata")] +[JsonDerivedType(typeof(SysmlDependencyNode), "dependency")] public abstract class SysmlNode { /// @@ -52,6 +53,16 @@ public abstract class SysmlNode /// /// Gets the supertype names referenced by specialization. /// + /// + /// For specifically, this field is populated only by + /// AstBuilder.ExtractSubsettingTargetNames (the subsets <target> / + /// :> feature-relationship form) — no other code path populates a feature + /// node's . It is therefore, implicitly, a subsetting-only + /// field on feature nodes; definition-level specialization (subclassificationPart, + /// e.g. part def RacingDrone :> Drone) is a structurally separate grammar rule + /// that populates 's own + /// through a different code path and is unaffected by this remark. + /// public IReadOnlyList SupertypeNames { get; init; } = Array.Empty(); /// @@ -357,10 +368,11 @@ public sealed class SysmlViewpointNode : SysmlNode public sealed class SysmlConnectionNode : SysmlNode { /// - /// Gets the connection keyword. One of "connection", "message", or - /// "allocation" (the latter reusing this node's endpoint shape for - /// allocate A to B, since allocationUsageDeclaration's connectorPart - /// is the exact same grammar rule used by connectionUsage). + /// Gets the connection keyword. One of "connection", "message", + /// "allocation", or "binding" (the latter two reusing this node's endpoint + /// shape for allocate A to B and bind A = B respectively, since + /// allocationUsageDeclaration's and bindingConnectorAsUsage's + /// connectorPart is the exact same grammar rule used by connectionUsage). /// public string ConnectionKeyword { get; init; } = string.Empty; @@ -423,3 +435,28 @@ public sealed class SysmlSatisfyNode : SysmlNode /// public string? SubjectName { get; init; } } + +/// +/// AST node representing a standalone dependency (id)? (from A(,A)*)? to B(,B)*; +/// relationship. +/// +/// +/// Inherited from SysmlNode: Name, QualifiedName, Children, SupertypeNames, ImportedNames, +/// VerifiedRequirementNames, ResolvedEdges, Annotations. +/// +public sealed class SysmlDependencyNode : SysmlNode +{ + /// + /// Gets the raw reference text of the client ("from") names. Populated for both the + /// explicit shape (dependency from A to B;) and the implicit-from shape with the + /// from keyword omitted (e.g. dependency z to x;, where z is still + /// classified as a from-name by its token position before to); empty only when no + /// qualified name at all precedes to (e.g. dependency to x;). + /// + public IReadOnlyList FromNames { get; init; } = Array.Empty(); + + /// + /// Gets the raw reference text of the supplier ("to") names. + /// + public IReadOnlyList ToNames { get; init; } = Array.Empty(); +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index 168841e0..82983ea1 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -356,11 +356,13 @@ public void GeneralViewLayoutStrategy_BuildLayout_CompositeMembership_ProducesFi } /// - /// A part def that owns a ref-typed feature emits a hollow-diamond line from the - /// referenced type box to the owning definition box (SysML v2 reference membership notation). + /// A part def that owns a ref-typed feature emits a Dependency-shaped edge (dashed + /// line, open chevron) from the owning definition to the referenced type box — the obsolete + /// hollow-diamond membership notation was removed in favor of current OMG SysML v2 notation + /// for reference usages, sharing the same rendering as the public Dependency edge kind. /// [Fact] - public void GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesHollowDiamondEdge() + public void GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesDependencyEdge() { // Arrange: System owns a ref typed as Engine; both are user definitions var strategy = new GeneralViewLayoutStrategy(); @@ -387,10 +389,12 @@ public void GeneralViewLayoutStrategy_BuildLayout_ReferenceMembership_ProducesHo // Act var layout = strategy.BuildLayout(context, options); - // Assert: a hollow-diamond arrowhead edge (EndMarkerStyle.HollowDiamond) is emitted for a ref feature - var membershipEdge = CollectLines(layout.Nodes) - .FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.HollowDiamond); - Assert.NotNull(membershipEdge); + // Assert: a dashed open-chevron edge is emitted for a ref feature, and no hollow-diamond + // edge is emitted anywhere (the obsolete notation is fully retired). + var lines = CollectLines(layout.Nodes); + var dependencyEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.OpenChevron && l.LineStyle == LineStyle.Dashed); + Assert.NotNull(dependencyEdge); + Assert.DoesNotContain(lines, l => l.TargetEnd == EndMarkerStyle.HollowDiamond); } /// @@ -1337,4 +1341,413 @@ private static void AssertDefinitionBoxesDoNotOverlap(IReadOnlyList } } } + + /// + /// A resolved edge between two sibling ports/parts of + /// different types (each nested inside the same enclosing definition, the dominant + /// real-world shape) emits a solid, unmarked line between the two distinct owning boxes — + /// confirming ResolveOwningBox's shortest-typed-feature-prefix walk correctly maps + /// each dotted-chain endpoint to a different box rather than self-looping back to the + /// shared enclosing definition. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Connect_DifferentOwningTypes_ProducesUnmarkedSolidEdge() + { + // Arrange: Drone owns "controller" (typed FlightController) and "battery" (typed Battery), + // each with a nested feature ("power"/"output"); a Connect edge links the two nested chains. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::FlightController"] = new SysmlDefinitionNode { Name = "FlightController", QualifiedName = "P::FlightController", DefinitionKeyword = "part def" }, + ["P::Battery"] = new SysmlDefinitionNode { Name = "Battery", QualifiedName = "P::Battery", DefinitionKeyword = "part def" }, + ["P::Drone"] = new SysmlDefinitionNode + { + Name = "Drone", + QualifiedName = "P::Drone", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "controller", QualifiedName = "P::Drone::controller", FeatureKeyword = "part", FeatureTyping = "FlightController" }, + new SysmlFeatureNode { Name = "battery", QualifiedName = "P::Drone::battery", FeatureKeyword = "part", FeatureTyping = "Battery" } + ] + }, + ["P::Drone::controller"] = new SysmlFeatureNode + { + Name = "controller", + QualifiedName = "P::Drone::controller", + FeatureKeyword = "part", + FeatureTyping = "FlightController", + ResolvedEdges = [new SysmlEdge("P::Drone::controller", "P::FlightController", SysmlEdgeKind.Typing)] + }, + ["P::Drone::battery"] = new SysmlFeatureNode + { + Name = "battery", + QualifiedName = "P::Drone::battery", + FeatureKeyword = "part", + FeatureTyping = "Battery", + ResolvedEdges = [new SysmlEdge("P::Drone::battery", "P::Battery", SysmlEdgeKind.Typing)] + } + }, + Index = new SemanticIndex( + [ + new SysmlEdge("P::Drone::controller::power", "P::Drone::battery::output", SysmlEdgeKind.Connect) + ]) + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a solid, unmarked line exists linking the FlightController and Battery boxes. + var lines = CollectLines(layout.Nodes); + var connectEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.None && l.LineStyle == LineStyle.Solid && l.MidpointLabel is null); + Assert.NotNull(connectEdge); + } + + /// + /// A connect edge whose two dotted-chain endpoints resolve to the same + /// owning box (e.g. two features of the same enclosing definition) produces no edge — the + /// self-loop guard that every other edge kind in this unit already applies. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Connect_SameOwningType_ProducesNoEdge() + { + // Arrange: Vehicle owns two features both typed as Port (the same owning box). + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Port"] = new SysmlDefinitionNode { Name = "Port", QualifiedName = "P::Port", DefinitionKeyword = "port def" }, + ["P::Vehicle"] = new SysmlDefinitionNode + { + Name = "Vehicle", + QualifiedName = "P::Vehicle", + DefinitionKeyword = "part def" + }, + ["P::Vehicle::portA"] = new SysmlFeatureNode + { + Name = "portA", + QualifiedName = "P::Vehicle::portA", + FeatureKeyword = "port", + FeatureTyping = "Port", + ResolvedEdges = [new SysmlEdge("P::Vehicle::portA", "P::Port", SysmlEdgeKind.Typing)] + }, + ["P::Vehicle::portB"] = new SysmlFeatureNode + { + Name = "portB", + QualifiedName = "P::Vehicle::portB", + FeatureKeyword = "port", + FeatureTyping = "Port", + ResolvedEdges = [new SysmlEdge("P::Vehicle::portB", "P::Port", SysmlEdgeKind.Typing)] + } + }, + Index = new SemanticIndex( + [ + new SysmlEdge("P::Vehicle::portA::sig", "P::Vehicle::portB::sig", SysmlEdgeKind.Connect) + ]) + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no Connect-shaped (unmarked solid) edge is emitted between Vehicle and itself. + var lines = CollectLines(layout.Nodes); + Assert.DoesNotContain(lines, l => l.TargetEnd == EndMarkerStyle.None && l.LineStyle == LineStyle.Solid); + + // Assert: the drop is surfaced as a visible warning (defense-in-depth diagnostic) rather + // than silently discarded. + Assert.Contains(layout.Warnings, w => w.Contains("Connect", StringComparison.Ordinal) && + w.Contains("P::Vehicle::portA::sig", StringComparison.Ordinal) && + w.Contains("P::Vehicle::portB::sig", StringComparison.Ordinal)); + } + + /// + /// End-to-end regression guard for the dominant real-world connect shape: two + /// sibling features (ports) declared directly in their owning part defs, referenced + /// from an enclosing part via bare part usages with no per-instance nested + /// redeclaration. This test runs the real (not a synthetic + /// ) and feeds the resulting workspace into the real + /// , proving the fix to + /// ReferenceResolver.TryResolveFeatureChain's instance-path preservation actually + /// reaches the rendering pipeline end to end for this shape — not just via hand-built + /// fixtures that assume already-correct resolver output. + /// + [Fact] + public async Task GeneralViewLayoutStrategy_BuildLayout_ConnectDominantShape_RealWorkspaceLoader_ProducesDistinctBoxes() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def PowerPort; + part def FlightController { + port power : PowerPort; + } + part def Battery { + port output : PowerPort; + } + part def Drone { + part controller : FlightController; + part battery : Battery; + connect controller.power to battery.output; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + Assert.NotNull(result.Workspace); + var workspace = result.Workspace!; + + var strategy = new GeneralViewLayoutStrategy(); + var options = new RenderOptions(Themes.Light); + var context = new ViewContext("v", workspace); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a solid, unmarked Connect edge exists between the FlightController and + // Battery boxes, and no dropped-edge warning is emitted for it. + var lines = CollectLines(layout.Nodes); + var connectEdge = lines.FirstOrDefault(l => + l.TargetEnd == EndMarkerStyle.None && l.LineStyle == LineStyle.Solid && l.MidpointLabel is null); + Assert.NotNull(connectEdge); + + var boxes = CollectBoxes(layout.Nodes); + var boxLabels = boxes.Select(b => b.Label).ToList(); + Assert.Contains("FlightController", boxLabels); + Assert.Contains("Battery", boxLabels); + Assert.DoesNotContain(layout.Warnings, w => w.Contains("Connect", StringComparison.Ordinal)); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A resolved edge between two definitions emits a + /// dashed, open-chevron edge carrying the «allocate» midpoint label. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Allocate_ProducesDashedChevronEdgeWithLabel() + { + // Arrange: an Allocate edge from FlightTimeRequirement to Battery. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::FlightTimeRequirement"] = new SysmlDefinitionNode { Name = "FlightTimeRequirement", QualifiedName = "P::FlightTimeRequirement", DefinitionKeyword = "requirement def" }, + ["P::Battery"] = new SysmlDefinitionNode { Name = "Battery", QualifiedName = "P::Battery", DefinitionKeyword = "part def" } + }, + Index = new SemanticIndex( + [ + new SysmlEdge("P::FlightTimeRequirement", "P::Battery", SysmlEdgeKind.Allocate) + ]) + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a dashed open-chevron edge with the "«allocate»" label is emitted. + var lines = CollectLines(layout.Nodes); + var allocateEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.OpenChevron && l.LineStyle == LineStyle.Dashed && l.MidpointLabel == "«allocate»"); + Assert.NotNull(allocateEdge); + } + + /// + /// A resolved standalone edge between two + /// definitions produces the same dashed, open-chevron rendering as the ref-keyword + /// fix (), + /// confirming both sources share one visual identity. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Dependency_ProducesDashedChevronEdge() + { + // Arrange: a standalone dependency from FlightController to Battery. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::FlightController"] = new SysmlDefinitionNode { Name = "FlightController", QualifiedName = "P::FlightController", DefinitionKeyword = "part def" }, + ["P::Battery"] = new SysmlDefinitionNode { Name = "Battery", QualifiedName = "P::Battery", DefinitionKeyword = "part def" } + }, + Index = new SemanticIndex( + [ + new SysmlEdge("P::FlightController", "P::Battery", SysmlEdgeKind.Dependency) + ]) + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a dashed open-chevron edge (no label) is emitted. + var lines = CollectLines(layout.Nodes); + var dependencyEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.OpenChevron && l.LineStyle == LineStyle.Dashed && l.MidpointLabel is null); + Assert.NotNull(dependencyEdge); + } + + /// + /// A resolved edge between two dotted feature chains + /// resolves through ResolveOwningBox the same way Connect does, and emits a + /// solid, unmarked edge carrying the = midpoint label distinguishing it from Connect. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Binding_ProducesSolidEdgeWithEqualsLabel() + { + // Arrange: Vehicle owns "engine" (typed Engine) and "gauge" (typed Gauge); a Binding links + // a feature nested under each. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Engine"] = new SysmlDefinitionNode { Name = "Engine", QualifiedName = "P::Engine", DefinitionKeyword = "part def" }, + ["P::Gauge"] = new SysmlDefinitionNode { Name = "Gauge", QualifiedName = "P::Gauge", DefinitionKeyword = "part def" }, + ["P::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "P::Vehicle", DefinitionKeyword = "part def" }, + ["P::Vehicle::engine"] = new SysmlFeatureNode + { + Name = "engine", + QualifiedName = "P::Vehicle::engine", + FeatureKeyword = "part", + FeatureTyping = "Engine", + ResolvedEdges = [new SysmlEdge("P::Vehicle::engine", "P::Engine", SysmlEdgeKind.Typing)] + }, + ["P::Vehicle::gauge"] = new SysmlFeatureNode + { + Name = "gauge", + QualifiedName = "P::Vehicle::gauge", + FeatureKeyword = "part", + FeatureTyping = "Gauge", + ResolvedEdges = [new SysmlEdge("P::Vehicle::gauge", "P::Gauge", SysmlEdgeKind.Typing)] + } + }, + Index = new SemanticIndex( + [ + new SysmlEdge("P::Vehicle::engine::rpm", "P::Vehicle::gauge::value", SysmlEdgeKind.Binding) + ]) + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a solid, unmarked edge with the "=" label is emitted. + var lines = CollectLines(layout.Nodes); + var bindingEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.None && l.LineStyle == LineStyle.Solid && l.MidpointLabel == "="); + Assert.NotNull(bindingEdge); + } + + /// + /// A subtype narrowing an inherited feature via subsets (e.g. + /// part frontMotors : Motor[2] subsets motors; where motors is declared on + /// the supertype) emits a dashed hollow-triangle edge from the subtype to the ancestor + /// definition that declares the subsetted feature — reusing the same + /// ResolveRedefinitionOwner ancestor-chain walk as Redefinition, but rendered dashed + /// to distinguish it from solid Specialization/Redefinition edges. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_Subsetting_CrossesSpecializationBoundary_ProducesDashedHollowTriangleEdge() + { + // Arrange: Drone declares "motors"; RacingDrone specializes Drone and subsets "motors" via + // a narrower "frontMotors" feature. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Motor"] = new SysmlDefinitionNode { Name = "Motor", QualifiedName = "P::Motor", DefinitionKeyword = "part def" }, + ["P::Drone"] = new SysmlDefinitionNode + { + Name = "Drone", + QualifiedName = "P::Drone", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "motors", QualifiedName = "P::Drone::motors", FeatureKeyword = "part", FeatureTyping = "Motor", Multiplicity = "[4]" } + ] + }, + ["P::RacingDrone"] = new SysmlDefinitionNode + { + Name = "RacingDrone", + QualifiedName = "P::RacingDrone", + DefinitionKeyword = "part def", + SupertypeNames = ["Drone"], + Children = + [ + new SysmlFeatureNode { Name = "frontMotors", QualifiedName = "P::RacingDrone::frontMotors", FeatureKeyword = "part", FeatureTyping = "Motor", Multiplicity = "[2]", SupertypeNames = ["motors"] } + ] + } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a dashed hollow-triangle edge from RacingDrone to Drone is emitted (distinct + // from the solid hollow-triangle Specialization edge also present between the same boxes). + var lines = CollectLines(layout.Nodes); + var subsettingEdge = lines.FirstOrDefault(l => l.TargetEnd == EndMarkerStyle.HollowTriangle && l.LineStyle == LineStyle.Dashed); + Assert.NotNull(subsettingEdge); + Assert.Contains(lines, l => l.TargetEnd == EndMarkerStyle.HollowTriangle && l.LineStyle == LineStyle.Solid); + } + + /// + /// A subsets reference to a sibling feature declared on the very same definition + /// (no specialization boundary crossed) produces no edge — a known limitation mirroring + /// 's + /// documented self-reference behavior for Redefinition. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_SelfReferentialSubsetting_ProducesNoEdge() + { + // Arrange: Vehicle declares both "wheels" and a same-definition sibling "frontWheels" that + // subsets "wheels" with no supertype to walk. + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["P::Wheel"] = new SysmlDefinitionNode { Name = "Wheel", QualifiedName = "P::Wheel", DefinitionKeyword = "part def" }, + ["P::Vehicle"] = new SysmlDefinitionNode + { + Name = "Vehicle", + QualifiedName = "P::Vehicle", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "wheels", QualifiedName = "P::Vehicle::wheels", FeatureKeyword = "part", FeatureTyping = "Wheel", Multiplicity = "[4]" }, + new SysmlFeatureNode { Name = "frontWheels", QualifiedName = "P::Vehicle::frontWheels", FeatureKeyword = "part", FeatureTyping = "Wheel", Multiplicity = "[2]", SupertypeNames = ["wheels"] } + ] + } + } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no dashed hollow-triangle (Subsetting) edge is emitted. + var lines = CollectLines(layout.Nodes); + Assert.DoesNotContain(lines, l => l.TargetEnd == EndMarkerStyle.HollowTriangle && l.LineStyle == LineStyle.Dashed); + } } + diff --git a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs index 82111baf..466b28bf 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs @@ -3,6 +3,7 @@ using DemaConsulting.SysML2Tools.Parser; using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; using DemaConsulting.SysML2Tools.Stdlib; namespace DemaConsulting.SysML2Tools.Tests.Parser; @@ -70,4 +71,97 @@ public async Task OmgModels_FileCount_IsExpected() $"Expected at least 251 OMG model files, found {files.Length}"); await Task.CompletedTask; } + + /// + /// The dedicated standalone-dependency corpus fixtures (both the from/to + /// comma-list form and the no-FROM-keyword implicit-from form) resolve into the + /// expected edges, with no unresolved-reference + /// diagnostics — confirming VisitDependency's token-position from/to split and + /// ReferenceResolver's from×to cross-product resolution against real, quoted, + /// multi-word OMG model names (not just synthetic single-word fixtures). + /// + [Fact] + public async Task Dependency_OmgCorpusFixtures_ResolveExpectedEdges() + { + var omgRoot = FindOmgModelsRoot(); + var files = new[] + { + Path.Combine(omgRoot, "training", "37.Dependencies", "DependencyExample.sysml"), + Path.Combine(omgRoot, "validation", "12-DependencyRelationships", "12a-Dependency.sysml"), + }; + Assert.All(files, f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync(files, stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning); + + var dependencyEdges = result.Workspace!.Index.AllEdges + .Where(e => e.Kind == SysmlEdgeKind.Dependency) + .Select(e => (Source: e.SourceQualifiedName ?? string.Empty, Target: e.TargetQualifiedName)) + .ToList(); + + // DependencyExample.sysml: two dependency statements — one with a single from/to pair, + // one with a single "from" and a comma-separated list of two "to" names (cross product). + Assert.Contains( + ("'Dependency Example'::'System Assembly'::'Computer Subsystem'", "'Dependency Example'::'Software Design'"), + dependencyEdges); + Assert.Contains( + ("'Dependency Example'::'System Assembly'::'Storage Subsystem'", "'Dependency Example'::'Software Design'::MessageSchema"), + dependencyEdges); + Assert.Contains( + ("'Dependency Example'::'System Assembly'::'Storage Subsystem'", "'Dependency Example'::'Software Design'::DataSchema"), + dependencyEdges); + + // 12a-Dependency.sysml: two package-to-package statements, plus the no-FROM-keyword + // implicit-from shape ("dependency z to x, y;") producing a from×to cross product. + Assert.Contains(("'12a-Dependency'::'Application Layer'", "'12a-Dependency'::'Service Layer'"), dependencyEdges); + Assert.Contains(("'12a-Dependency'::'Service Layer'", "'12a-Dependency'::'Data Layer'"), dependencyEdges); + Assert.Contains(("'12a-Dependency'::z", "'12a-Dependency'::x"), dependencyEdges); + Assert.Contains(("'12a-Dependency'::z", "'12a-Dependency'::y"), dependencyEdges); + + Assert.Equal(7, dependencyEdges.Count); + } + + /// + /// The dedicated bind corpus fixture parses and resolves its two bind + /// statements, whose endpoints reference a port/item usage declared only via + /// port redefines fuelTankPort { out item redefines fuelSupply; ... } — an implicitly + /// named usage (no explicit name token precedes redefines). Per SysML v2 semantics + /// such a usage's implicit name is the name of the feature it redefines, so + /// fuelTankPort/fuelSupply etc. are resolvable simple names despite never + /// being written explicitly — AstBuilder.BuildUsageNode's effectiveName + /// fallback derives them from . + /// + [Fact] + public async Task Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames() + { + var omgRoot = FindOmgModelsRoot(); + var files = new[] + { + Path.Combine(omgRoot, "training", "12.BindingConnectors", "BindingConnectorsExample-1.sysml"), + Path.Combine(omgRoot, "training", "10.Ports", "PortExample.sysml"), + }; + Assert.All(files, f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync(files, stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + var bindingEdges = result.Workspace!.Index.AllEdges + .Where(e => e.Kind == SysmlEdgeKind.Binding) + .Select(e => (Source: e.SourceQualifiedName ?? string.Empty, Target: e.TargetQualifiedName)) + .ToList(); + + Assert.Contains( + ("'Binding Connectors Example-1'::vehicle::tank::fuelTankPort::fuelSupply", + "'Binding Connectors Example-1'::vehicle::tank::pump::pumpOut"), + bindingEdges); + Assert.Contains( + ("'Binding Connectors Example-1'::vehicle::tank::fuelTankPort::fuelReturn", + "'Binding Connectors Example-1'::vehicle::tank::tank::fuelIn"), + bindingEdges); + Assert.Equal(2, bindingEdges.Count); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 3e9f1ade..bf01f36c 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -1005,6 +1005,52 @@ part def Vehicle { } } + /// + /// An implicitly-named redefining usage whose redefines reference is a dot-chained + /// feature path (e.g. tank.fuelTankPort, grammatically distinct from a ::-qualified + /// name — ownedRedefinition is qualifiedName ( DOT qualifiedName )*) must still + /// take only the trailing simple-name segment (fuelTankPort), not the whole dotted + /// reference text, as its implicit name. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ImplicitNameFromDottedRedefinitionChain_UsesTrailingSegment() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + part def Vehicle { + part tank { + port fuelTankPort; + } + port redefines tank.fuelTankPort { + item item1; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + var vehicle = Assert.IsType( + result.Workspace!.Declarations["Vehicle"]); + var implicitlyNamedPort = vehicle.Children + .OfType() + .First(f => f.RedefinedFeatureName == "tank.fuelTankPort"); + + Assert.Equal("fuelTankPort", implicitlyNamedPort.Name); + Assert.Equal("Vehicle::fuelTankPort", implicitlyNamedPort.QualifiedName); + } + finally + { + File.Delete(tempFile); + } + } + /// /// A resolved redefined-feature reference should be recorded as a Redefinition edge /// in the workspace's , queryable from both directions. @@ -1940,6 +1986,268 @@ part def Q {} } } + /// + /// A dependency A to B; declaration with both ends resolvable should be recorded as + /// a Dependency edge from the resolved client to the resolved supplier. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_DependencyBinaryEnds_RecordsDependencyEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def Q {} + part a : Q; + part b : Q; + dependency a to b; + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Dependency && + e.SourceQualifiedName == "P::a" && + e.TargetQualifiedName == "P::b"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A dependency declaration with comma-separated client and supplier lists should + /// produce one Dependency edge per resolved (client, supplier) pair (cross product). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_DependencyCommaLists_RecordsCrossProductEdges() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def Q {} + part a : Q; + part b : Q; + part c : Q; + part d : Q; + dependency a, b to c, d; + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + var edges = result.Workspace!.Index.AllEdges + .Where(e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Dependency) + .Select(e => (e.SourceQualifiedName, e.TargetQualifiedName)) + .ToList(); + Assert.Equal(4, edges.Count); + Assert.Contains(("P::a", "P::c"), edges); + Assert.Contains(("P::a", "P::d"), edges); + Assert.Contains(("P::b", "P::c"), edges); + Assert.Contains(("P::b", "P::d"), edges); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A dependency declaration with an unresolvable end should produce a Warning + /// diagnostic and must not produce a Dependency edge (no partial edge, no crash). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_DependencyUnresolvedEnd_ProducesWarningNoEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def Q {} + part a : Q; + dependency a to nonExistentEnd; + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Diagnostics, + d => d.Severity == DemaConsulting.SysML2Tools.Parser.DiagnosticSeverity.Warning && + d.Message.Contains("nonExistentEnd")); + Assert.DoesNotContain(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Dependency); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A bind a.x = b.y; binding connector usage with a resolvable dotted feature chain + /// on both sides should be recorded as a Binding edge between the resolved features, + /// reusing the same dotted-feature-chain walk as connect. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def Sensor { + port x; + } + part def Display { + port y; + } + part def Q { + part a : Sensor; + part b : Display; + bind a.x = b.y; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Binding && + e.SourceQualifiedName == "P::Q::a::x" && + e.TargetQualifiedName == "P::Q::b::y"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A bind endpoint that names an implicitly-named usage (a nested + /// port redefines fuelTankPort { ... } with no name token of its own) should still + /// resolve and produce a Binding edge, because such a usage's implicit name is the + /// name of the feature it redefines (SysML v2 semantics) — AstBuilder.BuildUsageNode's + /// effectiveName fallback derives it from RedefinedFeatureName rather than + /// leaving the usage permanently unnamed and unresolvable. Mirrors the real-world OMG + /// corpus fixture shape (BindingConnectorsExample-1.sysml / PortExample.sysml) + /// in isolation, independent of the external corpus. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_BindingViaImplicitlyNamedRedefinedUsage_RecordsBindingEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + port def FuelOutPort { + item fuelSupply; + } + part def Tank { + port fuelTankPort : FuelOutPort; + } + part def Pump { + item pumpOut; + } + part def Vehicle { + part tank : Tank { + port redefines fuelTankPort { + item redefines fuelSupply; + } + } + part pump : Pump; + bind tank.fuelTankPort.fuelSupply = pump.pumpOut; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Binding && + e.SourceQualifiedName == "P::Vehicle::tank::fuelTankPort::fuelSupply" && + e.TargetQualifiedName == "P::Vehicle::pump::pumpOut"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A bind usage referencing an unresolvable feature chain end should produce a + /// Warning diagnostic and must not produce a Binding edge (no partial edge, no + /// crash). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def Sensor { + port x; + } + part def Q { + part a : Sensor; + bind a.x = nonExistentEnd.y; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Diagnostics, + d => d.Severity == DemaConsulting.SysML2Tools.Parser.DiagnosticSeverity.Warning && + d.Message.Contains("nonExistentEnd")); + Assert.DoesNotContain(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Binding); + } + finally + { + File.Delete(tempFile); + } + } + /// /// A fixture combining satisfy, verify, and allocate should let the /// workspace's answer both incoming and outgoing edge @@ -2129,8 +2437,8 @@ part vehicle { Assert.NotNull(result.Workspace); Assert.Contains(result.Workspace!.Index.AllEdges, e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Connect && - e.SourceQualifiedName == "P::Engine::fuelCmdPort" && - e.TargetQualifiedName == "P::Transmission::input"); + e.SourceQualifiedName == "P::vehicle::engine::fuelCmdPort" && + e.TargetQualifiedName == "P::vehicle::transmission::input"); } finally { @@ -2174,8 +2482,8 @@ part rearAxle { Assert.NotNull(result.Workspace); Assert.Contains(result.Workspace!.Index.AllEdges, e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Connect && - e.SourceQualifiedName == "P::HalfAxle::axleToWheelPort" && - e.TargetQualifiedName == "P::Wheel::wheelToAxlePort"); + e.SourceQualifiedName == "P::rearAxle::leftHalfAxle::axleToWheelPort" && + e.TargetQualifiedName == "P::leftWheel::wheelToAxlePort"); } finally { @@ -2218,8 +2526,113 @@ part def Wheel { Assert.NotNull(result.Workspace); Assert.Contains(result.Workspace!.Index.AllEdges, e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Connect && - e.SourceQualifiedName == "P::AxleAssembly::shaftPort" && - e.TargetQualifiedName == "P::Wheel::wheelToAxlePort"); + e.SourceQualifiedName == "P::rearAxleAssembly::shaftPort" && + e.TargetQualifiedName == "P::leftWheel::wheelToAxlePort"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// The mirror image of : + /// a 3-segment chain where the *first* hop resolves via typing fallback (rearAxle has + /// no own leftHalfAxle usage, so it is found on Axle's own hierarchy) and the + /// *second* hop is then a direct child of that type-declared node (Axle::leftHalfAxle + /// has its own inline axleToWheelPort nested directly beneath it). Once a chain has + /// entered type-fallback territory, every remaining segment is still only reachable relative + /// to the type, not the instance — even one that is itself a "direct child" match — so the + /// final qualified name must remain instance-relative (P::rearAxle::leftHalfAxle::axleToWheelPort) + /// rather than collapsing back to the type's own declared path + /// (P::Axle::leftHalfAxle::axleToWheelPort) once the direct-child hop occurs. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConnectionThreeSegmentChain_DirectChildAfterTypingFallbackStaysInstanceRelative() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def HalfAxle { + port axleToWheelPort; + } + part def Axle { + part leftHalfAxle : HalfAxle { + port axleToWheelPort; + } + } + part def Wheel { + port wheelToAxlePort; + } + part rearAxle : Axle; + part leftWheel : Wheel; + connect rearAxle.leftHalfAxle.axleToWheelPort to leftWheel.wheelToAxlePort; + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Connect && + e.SourceQualifiedName == "P::rearAxle::leftHalfAxle::axleToWheelPort" && + e.TargetQualifiedName == "P::leftWheel::wheelToAxlePort"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// The dominant real-world connect shape: two sibling features (ports) declared + /// directly in their owning part defs, referenced from an enclosing part via bare + /// part usages with no per-instance nested redeclaration. Both endpoints resolve via + /// the typing-fallback branch, and each must produce a distinct, instance-relative qualified + /// name (Drone::controller::power, Drone::battery::output) rather than + /// collapsing to the shared port type's own declared path — the root-cause regression this + /// fix addresses. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConnectionDominantShape_ResolvesDistinctInstancePaths() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + part def PowerPort; + part def FlightController { + port power : PowerPort; + } + part def Battery { + port output : PowerPort; + } + part def Drone { + part controller : FlightController; + part battery : Battery; + connect controller.power to battery.output; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Connect && + e.SourceQualifiedName == "P::Drone::controller::power" && + e.TargetQualifiedName == "P::Drone::battery::output"); } finally { diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs index 44d2fdd2..4db004af 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Query/QueryVerbsTests.cs @@ -327,10 +327,10 @@ part def Vehicle { """; var (output, exitCode) = await QueryTestFixtures.RunQueryAsync( - sysml, "connections", "--element", "Model::Engine::fuelPort"); + sysml, "connections", "--element", "Model::Vehicle::engine"); Assert.Equal(0, exitCode); - Assert.Contains("Model::Tank::outlet", output); + Assert.Contains("Model::Vehicle::tank::outlet", output); Assert.Contains("connection", output); }