diff --git a/ROADMAP.md b/ROADMAP.md index d7d24be3..e26d0052 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,27 +101,54 @@ 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. +### State Transition View: attached-transition states, entry/exit actions, inherited pseudostate features + +Investigation for this item (cross-checking the real OMG corpus fixture +`training/25.Transitions/TransitionActions.sysml` and the OMG spec's own worked example, `formal-26-03-02.md` +Annex A.7 "States") found the actual gap is significantly larger than originally scoped here, and is a +correctness bug, not just a missing notation refinement: + +1. **Attached-transition state bodies are silently dropped (both the state AND its transition).** + SysML v2's most common compact state-machine idiom writes a state's outgoing transition directly after it + with no `transition`/`first` keyword at all, e.g. `state off; accept Sig then starting;` — grammatically + `stateBodyItem: (sourceSuccessionMember)? behaviorUsageMember (targetTransitionUsageMember)*`, where the + transition's source is *implicitly* the immediately preceding state usage in the same body item. The + parallel shape `entryActionMember (entryTransitionMember)*` (e.g. `entry action initial; then off;`) has + the identical problem. `AstBuilder` has no visitor override for either shape, so ANTLR's default + `VisitChildren` (which returns only the last child's result, discarding earlier ones) causes **both** the + preceding usage and its attached transition(s) to vanish — confirmed: `vehicleStates` in the fixture above + should register 4 states and 4 transitions, but exporting it today yields 0 states and 1 transition, plus + "Unresolved reference" warnings for the never-registered state names. +2. **Entry/do/exit action features are entirely unmodeled.** `entryActionMember`, `doActionMember`, + `exitActionMember` (and their nested `statePerformActionUsage`/`stateAcceptActionUsage`/etc.) have no + `AstBuilder` visitor at all — not even the well-formed, spec-preferred style of declaring a named entry + action and referencing it from a separate `transition` statement (OMG spec Annex A.7: `entry action + initial; ... transition initial then off;`) currently registers a resolvable `initial` feature. +3. **Inherited pseudostate-like features don't resolve.** The training corpus's explicit form of an initial + transition, `first start then off;`, references `start` — a real feature every state definition/usage + inherits from `Action` (`Stdlib/SystemsLibrary/Actions.sysml`'s `action start: Action :>> startShot`), not + a special keyword. `ReferenceResolver`'s feature-chain walk only looks up local/imported names, not + inherited members, so `start` (and `done`, its counterpart) fail to resolve even once (1)/(2) are fixed. +4. **Initial-pseudostate marker rendering is already partially implemented but purely heuristic.** + `StateTransitionViewLayoutStrategy.AddInitialMarker` already draws the conventional filled-circle marker + with an arrow into the *first declared* state, unconditionally, regardless of whether a real initial + transition resolves. Once (1)-(3) let `start`/`initial`-sourced transitions resolve, the layout strategy + needs to: (a) prefer the semantically-resolved initial-transition target over the first-declared-state + guess when one exists, and (b) exclude pseudostate/entry-action source features (e.g. `start`, `initial`) + from being drawn as ordinary state boxes — today `CollectStates`'s "states referenced only by transition + endpoints" fallback would add them as a spurious extra box. + +**Scope:** `AstBuilder` (new handling for the `behaviorUsageMember (targetTransitionUsageMember)*` and +`entryActionMember (entryTransitionMember)*` state-body shapes, producing the preceding usage node plus one +`SysmlTransitionNode` per attached transition with an implicit `Source`; minimal feature-node support for +entry/do/exit action declarations so their names are resolvable); `ReferenceResolver`/`TryResolveFeatureChain` +(inherited-member lookup so `start`/`done` resolve); `StateTransitionViewLayoutStrategy` (prefer a resolved +initial transition over the first-declared-state heuristic; exclude pseudostate/entry-action sources from +ordinary state-box rendering). +**Visual gate:** a state machine using the `state X; accept ... then Y;` idiom renders every state and every +attached transition correctly; a `first start then InitialState;`-shaped (or `entry action initial; ... +transition initial then X;`-shaped) entry transition renders a filled-circle initial marker with an edge into +the correct (resolved) state, with no spurious `start`/`initial` box. ### Interconnection View: genuine cross-boundary connector routing diff --git a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index 91d2e715..3eddcf10 100644 --- a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -4,8 +4,9 @@ `StateTransitionViewLayoutStrategy` implements `ILayoutStrategy` to produce a State Transition View diagram. It renders state usages as rounded boxes placed top-to-bottom through -`LayeredPlacement`, an initial pseudo-state marker entering the first declared state, and transitions -as orthogonal arrows annotated with their guard conditions. +`LayeredPlacement`, an initial pseudo-state marker entering the semantically-correct initial +state (falling back to the first declared state), and transitions as orthogonal arrows annotated +with their guard conditions. ##### Data Model @@ -22,8 +23,9 @@ private records carry intermediate data: `StateItem` (a state with its computed Entry point. Resolves the view's `expose` scope via `ExposeScopeResolver.ResolveExposedScope`, selects the root state definition via `FindRoot(workspace, scope)`, collects its states via `CollectStates(root, theme, scope)`, resolves its transitions, places the state boxes, adds the -initial marker and the transition edges, and assembles the tree. Returns a minimal 200×100 empty -`LayoutTree` when no root or no states are found. +initial marker (targeting the semantically-resolved initial state when one is found, otherwise +the first-declared state) and the transition edges, and assembles the tree. Returns a minimal +200×100 empty `LayoutTree` when no root or no states are found. ###### `FindRoot(workspace, scope)` and `CollectStates(root, theme, scope)` @@ -34,17 +36,33 @@ nested definition and its ancestor can both be relevant), the most specific (dee qualified name) relevant candidate is preferred via `ExposeScopeResolver.IsMoreSpecificCandidate`, with the transition-count tie-break used only to break ties among equally specific candidates; this ordering does not apply when `scope` is `null`. `CollectStates` gathers the declared `state` -usages first (preserving declaration order so the first declared state becomes the initial -state), excluding — when a scope is resolved — any declared state feature whose qualified name -fails `ExposeScopeResolver.IsInSubjectScope`; it then adds any additional state named only by a -transition endpoint **unconditionally** (this second pass has no independent qualified name of its -own to scope against, since it exists solely because a transition names it), building a name → -index lookup. +usages first (preserving declaration order so the first declared state becomes the +fallback-heuristic initial state), excluding — when a scope is resolved — any declared state +feature whose qualified name fails `ExposeScopeResolver.IsInSubjectScope`; it then adds any +additional state named only by a transition endpoint, **except** when that endpoint is a +transition *source* whose name is a pseudostate name (`"start"`/`"done"`) or matches a declared +entry-action feature's name — such a source is never itself a state and must never be rendered as +a box (see "Pseudostate/Entry-Action Source Exclusion" below); building a name → index lookup, and +returning the set of excluded pseudostate/entry-action source names alongside it for +`FindInitialTargetIndex` to use. ###### `ResolveTransitions(root, index)` Maps each transition's source and target — by their last `::`-separated name segment — to state -indices, carrying the optional guard. +indices, carrying the optional guard. Because pseudostate/entry-action transition sources are +never added to `index` by `CollectStates`, a transition whose source is such a name is silently +dropped here exactly like any other unresolvable endpoint — no additional change was needed in +this method for the pseudostate exclusion to take effect. + +###### `FindInitialTargetIndex(root, pseudoSourceNames, index)` + +Scans the root's transitions for the first whose source name is in `pseudoSourceNames` +(`"start"`/`"done"`, or a declared entry-action feature's name), and returns the resolved +target's state index, or `null` when no such transition exists. `BuildLayout` uses this index in +preference to index 0 (the first-declared state) when placing the initial marker, falling back to +index 0 only when this returns `null` — preserving the pre-existing heuristic unchanged for every +model that declares no explicit initial-pseudostate/entry-action transition (including the +`03-elevator-state.sysml` gallery example prior to its own initial-transition addition). ###### Placement and routing @@ -98,6 +116,20 @@ existing name-lookup approach naturally omits any transition whose endpoint stat synthesized `--auto` view, whose `ViewNode` is `null`) resolves no scope, so `FindRoot` considers every candidate and `CollectStates` keeps every state, unchanged from the pre-scoping behavior. +##### Pseudostate/Entry-Action Source Exclusion + +A transition's source can name a pseudostate (`start`/`done`) or an entry-action feature rather +than a genuine declared state — the OMG Annex A.7 idioms `first start then off;` and +`entry action initial; then off;` both produce a `SysmlTransitionNode` whose `Source` is such a +name. Neither name is ever separately declared as a `state` usage, so before this exclusion +existed, `CollectStates`'s "additional state referenced only by a transition endpoint" pass would +synthesize a spurious box labelled `"start"` (or the entry action's name) purely because a +transition referenced it — a state box for something that is not a state. `CollectStates` now +seeds a `pseudoSourceNames` set with the literal names `"start"` and `"done"` plus every declared +`entry`-keyword feature's `Name`, and skips adding a transition's source-side name to the state +list whenever it appears in that set; the target side of any transition is never affected by this +exclusion, since a transition's target is always a genuine state. + ##### Error Handling Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible 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 792ef68c..64e6fa4d 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -29,6 +29,10 @@ stack with `::` to form the fully-qualified name. | `VisitRequirementUsage` | `RequirementUsageContext` | `SysmlFeatureNode` (`FeatureKeyword = "requirement"`) | | `VisitDependency` | `DependencyContext` | `SysmlDependencyNode` | | `VisitBindingConnectorAsUsage` | `BindingConnectorAsUsageContext` | `SysmlConnectionNode` (kind `binding`) | +| `VisitStateBodyItem` | `StateBodyItemContext` | `SysmlNode` or `MultiNodeCapture` (usage + transitions) | +| `VisitEntryActionMember` | `EntryActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "entry"`) | +| `VisitDoActionMember` | `DoActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "do"`) | +| `VisitExitActionMember` | `ExitActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "exit"`) | `GetDeclaredName(IdentificationContext)` handles the three grammar alternatives: @@ -242,6 +246,64 @@ placeholder form's feature typing (`verify requirement : ;`, via th because nothing else in `AstBuilder` currently visits into `requirementBody`/`caseBody` subtrees. +`VisitStateUsage` additionally calls `ExtractFeatureTyping(decl?.featureSpecializationPart())` and +sets the result on the constructed `SysmlFeatureNode`'s `FeatureTyping` property — previously this +was always left `null` for state usages, unlike every other usage kind built via `BuildUsageNode`. +This is a prerequisite for `StateTransitionViewLayoutStrategy`'s expose-scoping root selection +(which resolves an exposed usage's type via its `Typing` edge) and for +`ReferenceResolver.TryResolveInheritedActionMember` (below) to find the enclosing usage's +supertype when its source has no explicit `state usage : Type { ... }` form. + +**Attached-transition state bodies and entry/do/exit action features.** The `stateBodyItem` +grammar rule has six alternatives; two of them attach a transition directly onto the immediately +preceding usage within the same alternative rather than exposing it as a separate +`transitionUsageMember`: `behaviorUsageMember (targetTransitionUsageMember)*` (e.g. +`state off; accept Signal then starting;`) and `entryActionMember (entryTransitionMember)*` (e.g. +`entry action initial; then off;`). Before `VisitStateBodyItem` existed, `AstBuilder` only ever +visited the leading usage of each alternative (via the default `VisitChildren` aggregation) and +silently dropped every attached transition, so this common OMG Annex A.7 idiom produced no +transition edge at all. + +`VisitStateBodyItem` dispatches all six alternatives explicitly: + +- `nonBehaviorBodyItem`, `transitionUsageMember`, `doActionMember`, `exitActionMember` — passed + straight through to `Visit(...)` (no attached-transition shape applies). +- `behaviorUsageMember (targetTransitionUsageMember)*` — visits the usage, then calls + `BuildAttachedTransition` once per `targetTransitionUsageMember` entry, each producing a + `SysmlTransitionNode` whose `Source` is the visited usage's `Name`. +- `entryActionMember (entryTransitionMember)*` — visits the entry action (via + `VisitEntryActionMember`, below), then calls `BuildEntryAttachedTransition` once per + `entryTransitionMember` entry (handling both the `guardedTargetSuccession` and bare `THEN` + grammar alternatives), each producing a `SysmlTransitionNode` sourced from the entry action's + name. + +When one or more attached transitions are produced, `VisitStateBodyItem` returns a +`MultiNodeCapture` (a private nested `SysmlNode` subtype, mirroring the existing +`AnnotationCapture` sentinel pattern exactly) wrapping the usage plus its transition(s); when none +are produced, it returns the visited usage/pass-through result directly with no wrapping. +`CollectChildren` — the only collection helper that ever visits a `stateBodyItem()` (confirmed by +inspection: `VisitStateDefinition` and `VisitStateUsage` are the sole two call sites) — flattens +any `MultiNodeCapture.Nodes` it encounters into the resulting `Children` list, exactly as it +already does for `AnnotationCapture`. Like `AnnotationCapture`, `MultiNodeCapture` is never +registered as a `[JsonDerivedType]` and must never reach a real `Children` list; the same +single-call-site guarantee that protects `AnnotationCapture` protects it here. + +`VisitEntryActionMember`, `VisitDoActionMember`, and `VisitExitActionMember` each delegate to a +shared `BuildStateActionFeatureNode(usage, keyword)` helper, which builds a **minimal** +`SysmlFeatureNode` — `FeatureKeyword` set to `"entry"`/`"do"`/`"exit"` respectively, `Children` +always empty — using `ExtractStateActionName` to determine the node's `Name`. Entry/do/exit +action *bodies* are behavioral (statement sequences: assignments, sends, control flow), which is +out of scope for this unit's declarative AST; this deliberately mirrors the existing +`VisitRequirementUsage` "minimal capture" pattern rather than attempting to model action-body +statements. `ExtractStateActionName` only derives a name for the named +`ACTION usageDeclaration?` grammar alternative (e.g. `do action providePower { ... }` → +`"providePower"`); for the unnamed reference-subsetting alternative (e.g. +`entry performSelfTest{...}`, a reference to an inherited/imported behavior rather than a new +declaration) it deliberately returns `null` rather than attempting to derive or evaluate a name — +the resulting feature node still registers as an (unnamed, unregistered) AST child so no +information is lost, but it is not itself a resolvable symbol. This scope boundary is intentional +and matches the ROADMAP's framing of entry/do/exit action support as "minimal, non-behavioral." + ##### Error Handling Anonymous elements (null declared names) are silently skipped — visitor methods return `null` 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 c38f6b7c..05ebd67a 100644 --- a/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md +++ b/docs/design/sysml2-tools-language/semantic/model/reference-resolver.md @@ -82,6 +82,14 @@ resolve — mirroring the existing Satisfy/Allocate both-sides-must-resolve cont appended to `node.ResolvedEdges` (pass-1 edges, if any, are preserved) and to the aggregate edge list. +For a `SysmlTransitionNode`, the `Source` side specifically is resolved via `ResolveTransitionSource` +rather than the plain `TryResolveFeatureChain` call used for `Target`: it tries +`TryResolveFeatureChain` first, and only when that fails, falls back to +`TryResolveInheritedActionMember` (see "Inherited Pseudostate Feature Resolution" below) before +the pair is given up on as unresolved. The `Target` side is never given this fallback — it is +scoped to `Source` only, matching the ROADMAP's own framing of this gap as specifically about an +implicit/inherited transition *source*. + `TryResolveFeatureChain(chain, namespaceStack, imports, out resolvedName)` splits the raw reference text on `.`. Segment 0 is resolved via the existing `TryResolve` four-step lookup (so it participates in the same scope/import resolution as any other single-name reference); a @@ -133,9 +141,50 @@ walk always terminates. no crash). - **Imported/wildcard-imported feature members are not merged into type member lookup** — only `Children` and `Supertype`-chain `Children` are searched. -- **An implied/omitted `SysmlTransitionNode.Source` produces no edge** — there is nothing to walk - a chain from, so no partial/misleading edge is emitted; this is a documented limitation, not a - defect. +- **An implied/omitted `SysmlTransitionNode.Source` produces no edge, except for the narrow + `start`/`done` inherited-pseudostate fallback described below** — there is nothing to walk a + chain from in the general case, so no partial/misleading edge is emitted; this remains a + documented limitation for every other implicit-source shape. + +##### Inherited Pseudostate Feature Resolution + +A transition's source commonly names a pseudostate feature (`start`/`done`) that the enclosing +state/action **inherits** from its supertype rather than declares itself — e.g. +`first start then off;` inside a `state usage : VehicleStates { ... }` with no local `start` +feature declared, or the synthesized `Source` of an attached transition following an +`entryActionMember`. `TryResolveFeatureChain` cannot see inherited members (it only resolves via +namespace/import scope), so without a dedicated fallback every such transition produced a false +"Unresolved reference" diagnostic and no `Transition` edge, even though the model is semantically +valid per the SysML v2 spec. + +`TryResolveInheritedActionMember(namespaceStack, name, out resolvedName)` is tried only after +`TryResolveFeatureChain` fails, and only for a `SysmlTransitionNode`'s `Source` (never `Target`): + +1. Looks up the enclosing node via `_symbolTable.Lookup(string.Join("::", namespaceStack))` — + the same joined-namespace-stack convention `TryResolveBareRedefinition` uses, since at the + point a `SysmlTransitionNode` is visited, `namespaceStack` reflects the enclosing state/usage + (transitions have no `Name` of their own to push). +2. If the enclosing node has a resolved `Typing` edge, follows it to the type node and searches + via `FindMemberInTypeHierarchy` (the same supertype-chain walk `TryResolveFeatureChain`'s + type-fallback branch uses). +3. Also tries `FindMemberInAncestorChain` directly on the enclosing node itself (covering the + `Supertype`/`Redefinition`-edge walk `TryResolveBareRedefinition` already performs elsewhere). +4. **Narrow, hardcoded last resort:** only when `name` is exactly `"start"` or `"done"`, and only + when the enclosing node has a state/action keyword (a `SysmlFeatureNode` with + `FeatureKeyword` `"state"`/`"action"`, or a `SysmlDefinitionNode` with `DefinitionKeyword` + `"state def"`/`"action def"`), looks up the standard library's `Actions::Action` type node's + direct children by name. This covers the common case of a `state def`/`state usage` with no + explicit `:>` supertype clause at all — whose implicit ultimate supertype, per the SysML v2 + standard library, is `Actions::Action` — since this codebase has no general + implicit-generalization/default-supertype inference mechanism (confirmed absent by inspection) + to derive that relationship automatically. + +This fallback is **intentionally narrow and not a general inherited-member resolver**: it is +scoped to Transition `Source` resolution only (step 4 additionally scoped to the two +well-known pseudostate names), consistent with how the ROADMAP itself frames this gap. A future +model using a different, custom state-machine base type that also needs `start`/`done`-like +inherited pseudostate features would not resolve via step 4 (only via steps 1–3, if it declares +an explicit supertype chain) — this is a known, accepted limitation, not a defect to fix here. ##### Requirement-Trace Edge Resolution @@ -248,7 +297,8 @@ unresolved names are present. - `SymbolTable` — `Contains` method used to check whether a supertype, typing, redefinition, or import name is registered; `Lookup` used by feature-chain resolution and `TryResolveBareRedefinition`/`FindMemberInAncestorChain` to walk from a resolved qualified name - back to its node. + back to its node; also used by `TryResolveInheritedActionMember` to look up the enclosing node + and (in its narrow last-resort branch) the stdlib `Actions::Action` type node. - `SysmlNode` hierarchy — traversed to collect `SupertypeNames`, `ImportedNames`, and `VerifiedRequirementNames`; checks for `SysmlFeatureNode.FeatureTyping` and `SysmlFeatureNode.RedefinedFeatureName`, `SysmlSatisfyNode` diff --git a/docs/gallery/README.md b/docs/gallery/README.md index c825e59a..440d40c4 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -98,7 +98,11 @@ SVG: [`svg/CoreLinkInterconnectionView.svg`](svg/CoreLinkInterconnectionView.svg ## 3. State Transition View — Elevator Controller Shows states placed top-to-bottom by the layered layout pipeline, an initial pseudo-state, and -guarded transitions routed as orthogonal `[guard]`-labelled arrows. +guarded transitions routed as orthogonal `[guard]`-labelled arrows. The initial marker targets +the state named by an explicit `first start then idle;` transition (rather than the +first-declared state), and the `idle`/`doorsClosing` pair uses the attached-transition idiom +(`state idle; accept callReceived then doorsClosing;`) instead of a standalone `transition` +statement. Model: [`models/03-elevator-state.sysml`](models/03-elevator-state.sysml) · SVG: [`svg/ElevatorStateTransitionView.svg`](svg/ElevatorStateTransitionView.svg) diff --git a/docs/gallery/models/03-elevator-state.sysml b/docs/gallery/models/03-elevator-state.sysml index a626d271..cb75869e 100644 --- a/docs/gallery/models/03-elevator-state.sysml +++ b/docs/gallery/models/03-elevator-state.sysml @@ -3,13 +3,14 @@ package ElevatorController { // An elevator control state machine. state def ElevatorStates { state idle; + accept callReceived then doorsClosing; state doorsOpening; state doorsOpen; state doorsClosing; state movingUp; state movingDown; - transition first idle if callReceived then doorsClosing; + first start then idle; transition first doorsClosing if doorsClosed then movingUp; transition first movingUp if atFloor then doorsOpening; transition first movingDown if atFloor then doorsOpening; diff --git a/docs/gallery/png/ElevatorStateTransitionView.png b/docs/gallery/png/ElevatorStateTransitionView.png index 2c59b7f2..3ce280fd 100644 Binary files a/docs/gallery/png/ElevatorStateTransitionView.png and b/docs/gallery/png/ElevatorStateTransitionView.png differ diff --git a/docs/gallery/svg/ElevatorStateTransitionView.svg b/docs/gallery/svg/ElevatorStateTransitionView.svg index eef536ba..d45c8fd2 100644 --- a/docs/gallery/svg/ElevatorStateTransitionView.svg +++ b/docs/gallery/svg/ElevatorStateTransitionView.svg @@ -1,4 +1,4 @@ - + @@ -30,40 +30,39 @@ - - «state» - idle - - «state» - doorsOpening - - «state» - doorsOpen - - «state» - doorsClosing + + «state» + idle + + «state» + doorsOpening + + «state» + doorsOpen + + «state» + doorsClosing «state» movingUp «state» movingDown - - - - - - - - - - - [callReceived] - [doorsClosed] - [atFloor] - [atFloor] - [doorsAreOpen] - [timeout] + + + + + + + + + + + [doorsClosed] + [atFloor] + [atFloor] + [doorsAreOpen] + [timeout] [goingDown] - [idleTimeout] + [idleTimeout] diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml index ca0d6502..8d876c16 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml @@ -143,3 +143,38 @@ sections: defeating the intended scoping. tests: - StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-PseudostateExclusion + title: >- + StateTransitionViewLayoutStrategy's CollectStates shall never render an ordinary state + box for a transition source named "start"/"done" or matching a declared entry-action + feature's name, even when that source is otherwise only referenced by a transition + endpoint. + justification: | + A pseudostate ("start"/"done") or entry-action-kind transition source is not itself a + state and must never be drawn as one; without this exclusion, the existing + "additional states referenced only by a transition endpoint" pass would synthesize a + spurious box labelled "start" (or the entry action's name) for every model using the + attached-transition or entry-action-transition idiom, since that source name is never + separately declared as a state. Only the transition source side is excluded — the + target side of any transition is unaffected. + tests: + - StateTransitionView_BuildLayout_PseudostateSourceTransition_NoSpuriousBox + - StateTransitionView_BuildLayout_EntryActionSourceTransition_NoSpuriousBoxUsesResolvedTarget + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-SemanticInitialMarker + title: >- + StateTransitionViewLayoutStrategy's BuildLayout shall place the initial pseudo-state + marker at the target of the first transition whose source is a pseudostate/entry-action + name, falling back to the first-declared state only when no such transition exists. + justification: | + The first-declared-state heuristic used previously is only an approximation of "where + the machine starts" — it is wrong whenever a model contains an explicit + `first start then X;` (or attached entry-action-transition) idiom naming a different + state as the true initial target. Preferring the semantically-resolved target keeps the + marker correct for every model using this idiom, while the fallback preserves the + existing behavior (and the `03-elevator-state.sysml` gallery example's original + rendering) for every model that declares no explicit initial transition. + tests: + - StateTransitionView_BuildLayout_PseudostateSourceTransition_MarksResolvedTargetNotFirstDeclared + - StateTransitionView_BuildLayout_NoExplicitInitialTransition_FallsBackToFirstDeclared 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 fe528564..3b8865f8 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -230,3 +230,45 @@ sections: - WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge - WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge - Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-AttachedTransitionStateBody + title: >- + AstBuilder shall synthesize a SysmlTransitionNode for each attached-transition + state body shape — a `behaviorUsageMember` followed by zero or more + `targetTransitionUsageMember`s, and an `entryActionMember` followed by zero or more + `entryTransitionMember`s — sourced from the preceding usage's name, in addition to + building the preceding usage itself. + justification: | + SysML v2's grammar attaches a `then`/`accept ... then`/guarded-succession + transition directly onto the immediately preceding state or entry-action usage + within the same `stateBodyItem` alternative, rather than as a standalone + `transitionUsageMember`. Previously, AstBuilder only visited the leading usage and + silently dropped every attached transition, so the OMG Annex A.7 idiom + `state off; accept Signal then starting;` produced no transition edge at all. A new + `MultiNodeCapture` sentinel (flattened by `CollectChildren`, mirroring the existing + `AnnotationCapture` pattern) lets a single `stateBodyItem` visit yield both the usage + node and one or more synthesized transition nodes. + tests: + - WorkspaceLoader_LoadAsync_AttachedTransitionAfterState_ResolvesSelfLoopEdge + - WorkspaceLoader_LoadAsync_MultipleAttachedTransitionsAfterState_CapturesAll + - WorkspaceLoader_LoadAsync_EntryActionWithAttachedTransition_CapturesEntryFeatureAndTransition + - WorkspaceLoader_LoadAsync_NamedEntryAction_RegistersResolvableFeature + - Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-EntryDoExitActionFeatures + title: >- + AstBuilder shall build a minimal SysmlFeatureNode (with FeatureKeyword "entry", + "do", or "exit" and no children) for each `entryActionMember`, `doActionMember`, and + `exitActionMember`, naming it only when the grammar's named `ACTION usageDeclaration?` + alternative supplies a declared name. + justification: | + Entry/do/exit action bodies are behavioral (statement sequences), which is out of + scope for this unit's declarative AST; capturing a minimal, non-behavioral feature + node (Children always empty) is sufficient to register a resolvable name in the + symbol table for the named alternative, while the unnamed reference-subsetting + alternative (e.g. `entry performSelfTest{...}`) intentionally yields a nameless + feature node rather than attempting to derive or evaluate a name. + tests: + - WorkspaceLoader_LoadAsync_UnnamedEntryActionReferenceForm_NoNameNoCrash + - WorkspaceLoader_LoadAsync_StateUsageWithExplicitTyping_RecordsTypingEdge + - Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions 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 4897d87a..78cca315 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/reference-resolver.yaml @@ -150,3 +150,30 @@ sections: - WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge - WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge - Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames + + - id: SysML2Tools-Language-Semantic-Model-ReferenceResolver-InheritedTransitionSource + title: >- + ReferenceResolver.ResolveFeatureChains shall, when resolving a SysmlTransitionNode's + Source, fall back to a narrowly-scoped inherited-action-member lookup + (TryResolveInheritedActionMember, reusing FindMemberInAncestorChain and + FindMemberInTypeHierarchy) after the standard feature-chain resolution fails, only for + the Source side — never the Target side. + justification: | + A transition's implicit source (e.g. `first start then off;`, or the bare `start` + feature an attached transition's synthesized Source implicitly names) commonly + references a pseudostate feature the enclosing state/action inherits from its + supertype rather than declares itself; the standard namespace/import-scoped lookup + cannot see inherited members, so without this fallback every such transition produced + a false "Unresolved reference" diagnostic and no Transition edge, even though the + model is semantically valid per the SysML v2 spec. As a narrow, deliberately + hardcoded last resort — only for the literal names "start"/"done", and only when the + enclosing node is a state- or action-keyword feature/definition — the fallback also + resolves against the standard library's `Actions::Action` direct children when no + user-declared supertype supplies the member, covering the common case of a `state def` + with no explicit `:>` clause (whose implicit ultimate supertype is `Actions::Action`). + This is intentionally not a general implicit-generalization/default-supertype resolver: + it is scoped to Transition Source resolution only, and to the two well-known + pseudostate-derived names, consistent with how the ROADMAP itself scopes this gap. + tests: + - WorkspaceLoader_LoadAsync_TransitionSourceStartFeature_ResolvesToStdlibActionMember + - Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions diff --git a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index 4393890d..124cc338 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -42,6 +42,14 @@ configuration are required beyond a standard .NET SDK installation. - A view whose resolved `Expose` edge names an inner state of a definition genuinely nested inside another eligible root candidate selects the nested definition, not the ancestor, even though the ancestor has more transitions and would win the old pure-score tie-break. +- A transition whose source is a pseudostate/entry-action name (`"start"`, or a declared + entry-action feature's name) is never rendered as its own state box. +- The initial-state marker's arrow lands on the resolved target of a pseudostate- or + entry-action-sourced transition, not the first-declared state, when such a transition exists. +- When no pseudostate/entry-action-sourced transition exists, the initial-state marker's arrow + still lands on the first-declared state, unchanged from the pre-existing heuristic (protecting + the `03-elevator-state.sysml` gallery example and every other model using no explicit initial + transition). ##### Test Scenarios @@ -60,3 +68,7 @@ configuration are required beyond a standard .NET SDK installation. | `StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState` | Isolated state dropped | | `StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | | `StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested | +| `StateTransitionView_BuildLayout_PseudostateSourceTransition_MarksResolvedTargetNotFirstDeclared` | Resolved target | +| `StateTransitionView_BuildLayout_PseudostateSourceTransition_NoSpuriousBox` | No `"start"` box | +| `StateTransitionView_BuildLayout_EntryActionSourceTransition_NoSpuriousBoxUsesResolvedTarget` | No entry box | +| `StateTransitionView_BuildLayout_NoExplicitInitialTransition_FallsBackToFirstDeclared` | Fallback unchanged | 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 7e1fa3b1..cd92c637 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -53,6 +53,25 @@ external services or additional configuration are required beyond a standard .NE `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). +- `VisitStateBodyItem` synthesizes an attached `SysmlTransitionNode` for the + `behaviorUsageMember (targetTransitionUsageMember)*` shape (e.g. `state off; accept Signal + then starting;`), including the repeated (`*`) case, and for the + `entryActionMember (entryTransitionMember)*` shape (e.g. `entry action initial; then off;`), + in each case in addition to building the preceding usage/entry-action node itself. +- A named entry action (the OMG Annex A.7-preferred separate `entry action initial; ... + transition initial then off;` form) registers a resolvable feature — no "Unresolved reference: + 'initial'" diagnostic is produced. +- An unnamed entry-action reference form (e.g. `entry performSelfTest{...}`) produces a feature + node with `Name == null` and does not crash. +- `VisitStateUsage` populates `FeatureTyping` from an explicit `state usage : Type { ... }` form, + recording the expected `Typing` edge (previously always dropped for state usages). +- A transition source named `"start"` with no locally-declared `start` feature resolves against + the standard library's `Actions::Action` via the new inherited-pseudostate-feature fallback, + producing a `Transition` edge and no unresolved-reference diagnostic. +- The real OMG corpus fixture `training/25.Transitions/TransitionActions.sysml` parses with no + unresolved-reference diagnostics for `start`/`off`/`starting`/`on`, and produces the exact + expected declared-state and resolved-transition counts, including entry/do/exit action feature + nodes on the `on` state. ##### Test Scenarios @@ -85,3 +104,11 @@ external services or additional configuration are required beyond a standard .NE | Binding dotted-chain resolution | `WorkspaceLoader_LoadAsync_BindingDottedChain_RecordsBindingEdge` | | Binding unresolved end | `WorkspaceLoader_LoadAsync_BindingUnresolvedEnd_ProducesWarningNoEdge` | | Binding OMG corpus fixture | `Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefinitionNames` | +| Attached transition (self-loop) | `WorkspaceLoader_LoadAsync_AttachedTransitionAfterState_ResolvesSelfLoopEdge` | +| Multiple attached transitions | `WorkspaceLoader_LoadAsync_MultipleAttachedTransitionsAfterState_CapturesAll` | +| Entry action | `WorkspaceLoader_LoadAsync_EntryActionWithAttachedTransition_CapturesEntryFeatureAndTransition` | +| Named entry action registers feature | `WorkspaceLoader_LoadAsync_NamedEntryAction_RegistersResolvableFeature` | +| Unnamed entry-action form, no crash | `WorkspaceLoader_LoadAsync_UnnamedEntryActionReferenceForm_NoNameNoCrash` | +| State usage explicit typing | `WorkspaceLoader_LoadAsync_StateUsageWithExplicitTyping_RecordsTypingEdge` | +| Transition source resolves | `WorkspaceLoader_LoadAsync_TransitionSourceStartFeature_ResolvesToStdlibActionMember` | +| OMG corpus fixture | `Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions` | 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 2cdc59b8..f9aa6d62 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md +++ b/docs/verification/sysml2-tools-language/semantic/model/reference-resolver.md @@ -53,6 +53,13 @@ external services or additional configuration are required beyond a standard .NE 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. +- A `SysmlTransitionNode`'s `Source` that names an inherited pseudostate feature (`"start"` + or `"done"`) not declared locally on the enclosing state/usage resolves via the new + `TryResolveInheritedActionMember` fallback — including the narrow last-resort case where no + explicit supertype is declared at all, resolving against the standard library's + `Actions::Action` direct children — producing a `Transition`-kind `SysmlEdge` and no + unresolved-reference diagnostic. The `Target` side of a transition never receives this + fallback. ##### Test Scenarios @@ -93,3 +100,5 @@ external services or additional configuration are required beyond a standard .NE | Unresolved chain endpoint | `WorkspaceLoader_LoadAsync_ConnectionUnresolvedEndpoint_ProducesWarningNoEdge` | | Supertype-cycle chain segment | `WorkspaceLoader_LoadAsync_ConnectionChain_SupertypeCycleTerminatesGracefully` | | OMG Connections example fixture | `WorkspaceLoader_LoadAsync_ConnectionsExampleFixture_RecordsConnectEdge` | +| Inherited transition source | `WorkspaceLoader_LoadAsync_TransitionSourceStartFeature_ResolvesToStdlibActionMember` | +| OMG Transitions corpus fixture | `Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions` | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index 2e56ebcc..a86576b6 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -66,7 +66,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return new LayoutTree(200.0, 100.0, []); } - var (states, index) = CollectStates(root, theme, scope); + var (states, index, pseudoSourceNames) = CollectStates(root, theme, scope); if (states.Count == 0) { return new LayoutTree(200.0, 100.0, []); @@ -112,8 +112,12 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) nodes.Add(MakeStateBox(states[i], stateRects[i])); } - // Initial pseudo-state entering the first declared state. - AddInitialMarker(stateRects[0], nodes); + // Initial pseudo-state entering the semantically-resolved initial target state when one + // exists (a real `first start then X;`/entry-action-sourced transition), falling back to + // the first-declared state heuristic otherwise (unchanged behavior for every model with no + // pseudostate-sourced transition, e.g. the gallery's 03-elevator-state.sysml). + var initialIndex = FindInitialTargetIndex(root, pseudoSourceNames, index) ?? 0; + AddInitialMarker(stateRects[initialIndex], nodes); // Transition edges (with guard labels), mapped from the algorithm's routed polylines. var crossings = AddTransitions(transitions, flowTransitions, placed.EdgePolylines, stateRects, offsetX, offsetY, nodes); @@ -265,14 +269,23 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th /// states synthesized only from transition endpoints have no independent qualified name and are /// always added, since they exist only because a transition endpoint that already named them /// exists on the (already scope-selected) root. + /// + /// Also collects and returns PseudoSourceNames: names that must never become an ordinary + /// state box even when referenced only as a transition's source. This is the well-known, + /// narrowly-scoped set of inherited pseudostate/action feature names ("start"/ + /// "done", consistent with the same simplification ReferenceResolver's + /// TryResolveInheritedActionMember fallback uses) plus every declared entry-keyword + /// feature's name (the entryActionMember (entryTransitionMember)* idiom's implicit + /// transition source) declared directly on . /// - private static (IReadOnlyList States, Dictionary Index) CollectStates( + private static (IReadOnlyList States, Dictionary Index, HashSet PseudoSourceNames) CollectStates( SysmlDefinitionNode root, Theme theme, ExposedScope? scope) { var states = new List(); var index = new Dictionary(StringComparer.Ordinal); + var pseudoSourceNames = new HashSet(StringComparer.Ordinal) { "start", "done" }; void Add(string name) { @@ -287,8 +300,16 @@ void Add(string name) } // Declared state usages first (preserves declaration order for the initial-state choice). + // Entry/do/exit action features (FeatureKeyword != "state") are always skipped as ordinary + // state boxes; a named entry action's name is additionally recorded in + // pseudoSourceNames since it can be an implicit transition source (sub-problem 2's shape). foreach (var feature in root.Children.OfType()) { + if (feature.FeatureKeyword == "entry" && feature.Name is { } entryName) + { + pseudoSourceNames.Add(entryName); + } + if (feature.FeatureKeyword != "state" || feature.Name is null) { continue; @@ -303,10 +324,14 @@ void Add(string name) Add(feature.Name); } - // Any additional states referenced only by transition endpoints. + // Any additional states referenced only by transition endpoints. The source side is + // skipped when it names a pseudostate/entry-action feature — such a name must never be + // drawn as an ordinary state box (it is rendered, if at all, only via the initial-marker + // arrow computed by FindInitialTargetIndex). The target side is unaffected: a real state + // reached only via such a transition is still a real state and must still get a box. foreach (var transition in root.Children.OfType()) { - if (LastSegment(transition.Source) is { } s) + if (LastSegment(transition.Source) is { } s && !pseudoSourceNames.Contains(s)) { Add(s); } @@ -317,7 +342,35 @@ void Add(string name) } } - return (states, index); + return (states, index, pseudoSourceNames); + } + + /// + /// Finds the state index that the semantically-resolved initial transition targets — the first + /// transition (in declaration order) whose source names a pseudostate/entry-action feature (see + /// 's pseudoSourceNames) — or when no + /// such transition exists (or its target does not resolve to a known state), in which case the + /// caller falls back to the pre-existing first-declared-state heuristic. + /// + private static int? FindInitialTargetIndex( + SysmlDefinitionNode root, + HashSet pseudoSourceNames, + Dictionary index) + { + foreach (var transition in root.Children.OfType()) + { + if (LastSegment(transition.Source) is not { } source || !pseudoSourceNames.Contains(source)) + { + continue; + } + + if (LastSegment(transition.Target) is { } target && index.TryGetValue(target, out var targetIndex)) + { + return targetIndex; + } + } + + return null; } /// Resolves transition endpoints to state indices via their last name segment. diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index 3e73ef8b..f20ab96f 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -245,6 +245,22 @@ private sealed class AnnotationCapture : SysmlNode public required SysmlAnnotation Annotation { get; init; } } + /// + /// Sentinel node used to carry more than one real up through the + /// generic ANTLR Visit pipeline from a single grammar alternative that actually + /// produces several sibling AST nodes (e.g. a stateBodyItem's attached-transition + /// shapes: the preceding state/entry-action usage plus one + /// per attached transition). Never appears in a real + /// list — always intercepts it and flattens its + /// into the owning node's children instead, mirroring how + /// is intercepted and routed into + /// rather than becoming a child itself. + /// + private sealed class MultiNodeCapture : SysmlNode + { + public required IReadOnlyList Nodes { get; init; } + } + /// public override SysmlNode? VisitPartDefinition(SysMLv2Parser.PartDefinitionContext context) { @@ -722,12 +738,20 @@ private static void CollectVerificationMembers(Antlr4.Runtime.Tree.IParseTree no /// public override SysmlNode? VisitStateUsage(SysMLv2Parser.StateUsageContext context) { - var name = GetDeclaredName(context.actionUsageDeclaration()?.usageDeclaration()?.identification()); + var decl = context.actionUsageDeclaration()?.usageDeclaration(); + var name = GetDeclaredName(decl?.identification()); if (name is null) { return null; } + // A state usage's own feature typing (e.g. `state vehicleStates : VehicleStates { ... }`) + // was previously dropped entirely — unlike BuildUsageNode's generic usage handling, no + // Typing edge was ever recorded for state usages. This is necessary so + // ReferenceResolver's inherited-pseudostate-feature fallback (start/done) can walk from + // this usage to its state def and on to Actions::Action via the Supertype chain. + var typing = ExtractFeatureTyping(decl?.featureSpecializationPart()); + // Collect the state body (nested state usages and transitions) as children, mirroring // VisitStateDefinition. Anonymous ("state x;") usages have no body items to collect. _namespaceStack.Add(name); @@ -739,11 +763,199 @@ private static void CollectVerificationMembers(Antlr4.Runtime.Tree.IParseTree no Name = name, QualifiedName = QualifyName(name), FeatureKeyword = "state", + FeatureTyping = typing, Children = children, Annotations = annotations, }; } + /// + /// Dispatches a single stateBodyItem alternative to the appropriate builder logic. + /// Most alternatives (nonBehaviorBodyItem, transitionUsageMember, + /// doActionMember, exitActionMember) already produce exactly one AST node via + /// the default ANTLR visitor dispatch, so they are simply passed through unchanged. The two + /// "attached transition" shapes — (sourceSuccessionMember)? behaviorUsageMember + /// (targetTransitionUsageMember)* and entryActionMember (entryTransitionMember)* + /// — each produce more than one sibling node (the preceding state/entry-action usage, plus + /// one per attached transition whose Source is + /// implicitly that preceding usage's declared name); without this override, ANTLR's default + /// VisitChildren aggregation silently discards every child result but the last, + /// dropping both the preceding usage and any earlier attached transitions. + /// + public override SysmlNode? VisitStateBodyItem(SysMLv2Parser.StateBodyItemContext context) + { + if (context.nonBehaviorBodyItem() is { } nonBehaviorBodyItem) + { + return Visit(nonBehaviorBodyItem); + } + + if (context.transitionUsageMember() is { } transitionUsageMember) + { + return Visit(transitionUsageMember); + } + + if (context.doActionMember() is { } doActionMember) + { + return Visit(doActionMember); + } + + if (context.exitActionMember() is { } exitActionMember) + { + return Visit(exitActionMember); + } + + if (context.behaviorUsageMember() is { } behaviorUsageMember) + { + var usageNode = Visit(behaviorUsageMember); + var targets = context.targetTransitionUsageMember(); + if (usageNode is null || targets.Length == 0) + { + return usageNode; + } + + var sourceName = usageNode.Name; + var nodes = new List { usageNode }; + foreach (var target in targets) + { + nodes.Add(BuildAttachedTransition(sourceName, target.targetTransitionUsage())); + } + + return new MultiNodeCapture { Nodes = nodes }; + } + + if (context.entryActionMember() is { } entryActionMember) + { + var entryNode = Visit(entryActionMember); + var transitions = context.entryTransitionMember(); + if (entryNode is null || transitions.Length == 0) + { + return entryNode; + } + + var sourceName = entryNode.Name; + var nodes = new List { entryNode }; + foreach (var transition in transitions) + { + nodes.Add(BuildEntryAttachedTransition(sourceName, transition)); + } + + return new MultiNodeCapture { Nodes = nodes }; + } + + return null; + } + + /// + /// Builds the for one targetTransitionUsageMember + /// attached after a state/action usage (e.g. accept Sig then starting;), whose + /// Source is implicitly the preceding usage's declared name (never present in the + /// grammar itself). Extraction mirrors 's handling of the + /// explicit transition ... then ...; form exactly (target via + /// , guard via the raw if expression text). + /// + private SysmlTransitionNode BuildAttachedTransition( + string? sourceName, + SysMLv2Parser.TargetTransitionUsageContext? usage) + { + var target = ConnectorEndReference( + usage?.transitionSuccessionMember()?.transitionSuccession()?.connectorEndMember()); + var guard = usage?.guardExpressionMember()?.ownedExpression()?.GetText(); + + return new SysmlTransitionNode + { + Source = sourceName, + Target = target, + Guard = guard, + }; + } + + /// + /// Builds the for one entryTransitionMember + /// attached after an entry action (e.g. entry action initial; ... then off;), + /// whose Source is implicitly the preceding entry action's declared name. Handles + /// both the guarded (guardedTargetSuccession: if ... then ...) and bare + /// (THEN transitionSuccessionMember) alternatives. + /// + private SysmlTransitionNode BuildEntryAttachedTransition( + string? sourceName, + SysMLv2Parser.EntryTransitionMemberContext transition) + { + var guarded = transition.guardedTargetSuccession(); + var successionMember = guarded?.transitionSuccessionMember() + ?? transition.transitionSuccessionMember(); + var target = ConnectorEndReference( + successionMember?.transitionSuccession()?.connectorEndMember()); + var guard = guarded?.guardExpressionMember()?.ownedExpression()?.GetText(); + + return new SysmlTransitionNode + { + Source = sourceName, + Target = target, + Guard = guard, + }; + } + + /// + public override SysmlNode? VisitEntryActionMember(SysMLv2Parser.EntryActionMemberContext context) + { + return BuildStateActionFeatureNode(context.stateActionUsage(), "entry"); + } + + /// + public override SysmlNode? VisitDoActionMember(SysMLv2Parser.DoActionMemberContext context) + { + return BuildStateActionFeatureNode(context.stateActionUsage(), "do"); + } + + /// + public override SysmlNode? VisitExitActionMember(SysMLv2Parser.ExitActionMemberContext context) + { + return BuildStateActionFeatureNode(context.stateActionUsage(), "exit"); + } + + /// + /// Builds a minimal, deliberately non-behavioral for an + /// entry/do/exit action member, registering only its declared name (so + /// it becomes a resolvable transition source per the OMG spec's Annex A.7 idiom, e.g. + /// entry action initial; ... transition initial then off;) with no children. The + /// nested action body's internal semantics — parameter bindings + /// (performSelfTest{ in vehicle = operatingVehicle; }), nested steps + /// (do action providePower { /* ... */ }), accept/send/assignment action-usage + /// alternatives — are deliberately NOT modeled; this tool only needs the named feature + /// itself to exist and be resolvable, not a full action-body AST. + /// + private SysmlFeatureNode BuildStateActionFeatureNode( + SysMLv2Parser.StateActionUsageContext? usage, + string keyword) + { + var name = ExtractStateActionName(usage); + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + FeatureKeyword = keyword, + Children = Array.Empty(), + Annotations = Array.Empty(), + }; + } + + /// + /// Extracts the declared name of a stateActionUsage, or when + /// the action is unnamed. Only the named ACTION usageDeclaration? alternative of + /// performActionUsageDeclaration (e.g. action providePower { ... }) yields a + /// name; the unnamed reference-subsetting form (e.g. entry performSelfTest{ ... }, + /// which subsets/references an existing behavior rather than declaring a new named + /// feature) and the stateAcceptActionUsage/stateSendActionUsage/ + /// stateAssignmentActionUsage/emptyActionUsage_ alternatives are out of scope + /// per ROADMAP.md — only NAMED entry actions need to be resolvable transition sources. + /// + private static string? ExtractStateActionName(SysMLv2Parser.StateActionUsageContext? usage) + { + var declaration = usage?.statePerformActionUsage()?.performActionUsageDeclaration(); + return GetDeclaredName(declaration?.usageDeclaration()?.identification()); + } + /// public override SysmlNode? VisitTransitionUsage(SysMLv2Parser.TransitionUsageContext context) { @@ -1625,6 +1837,10 @@ private static IReadOnlyList GetSubclassificationSupertypes( { annotations.Add(capture.Annotation); } + else if (node is MultiNodeCapture multi) + { + children.AddRange(multi.Nodes); + } else if (node is not null) { children.Add(node); diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs index 8be14370..8e96c8d9 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs @@ -943,10 +943,14 @@ private void ResolveFeatureChains( } // Transition source/target (an implied/omitted Source produces no edge — documented - // limitation, since there is nothing to walk a chain from). + // limitation, since there is nothing to walk a chain from). Source additionally falls + // back to TryResolveInheritedActionMember (see its doc comment) when the ordinary + // dotted-chain walk fails — this is the only reference kind allowed that fallback, since + // it exists specifically to resolve inherited pseudostate-like features (`start`/`done`) + // used as a transition's implicit initial source (e.g. `first start then off;`). if (node is SysmlTransitionNode transition) { - var resolvedSource = ResolveFeatureChainSide( + var resolvedSource = ResolveTransitionSource( transition.Source, filePath, resolvedInFile, namespaceStack, imports); var resolvedTarget = ResolveFeatureChainSide( transition.Target, filePath, resolvedInFile, namespaceStack, imports); @@ -1019,6 +1023,150 @@ private void ResolveFeatureChains( return null; } + /// + /// Resolves a 's Source side. Behaves exactly like + /// (same diagnostic/deduplication behavior) except + /// that, when the ordinary dotted-chain walk fails, it also tries + /// before giving up — the narrow fallback + /// that lets an implied initial-pseudostate transition (e.g. first start then off;) + /// resolve start/done, inherited members that ordinary scope/import + /// resolution can never see since they are not declared or imported anywhere in the + /// referencing file. This fallback is deliberately scoped to Transition Source only + /// — not Target, and not connection/binding endpoints — per ROADMAP.md's explicit + /// instruction. + /// + private string? ResolveTransitionSource( + string? source, + string filePath, + HashSet resolvedInFile, + IReadOnlyList namespaceStack, + IReadOnlyList imports) + { + if (source is not { Length: > 0 }) + { + return null; + } + + if (TryResolveFeatureChain(source, namespaceStack, imports, out var resolved)) + { + return resolved; + } + + if (TryResolveInheritedActionMember(namespaceStack, source, out var inherited)) + { + return inherited; + } + + if (resolvedInFile.Add(source)) + { + _diagnostics.Add(new SysmlDiagnostic( + filePath, + 0, 0, + DiagnosticSeverity.Warning, + $"Unresolved reference: '{source}'")); + } + + return null; + } + + /// + /// Narrow fallback for resolving a Transition Source that names a member inherited + /// from the enclosing state/action's type or ancestor chain, rather than declared/imported + /// directly — the shape needed for real SysML v2 initial-pseudostate/entry-action idioms + /// like first start then off;, where start is a real feature every state + /// definition/usage inherits from Actions::Action + /// (action start: Action :>> startShot), not a special keyword. + /// + /// Resolution order: (1) if the enclosing node (the current + /// top) has a resolved edge (e.g. a + /// state x : SomeStateDef { ... } usage), follow it to the type node and walk its + /// chain via ; + /// (2) failing that, walk the enclosing node's own + /// / edges + /// directly via (covers a state def that + /// itself declares an explicit :> supertype); (3) only if both fail, and only for + /// the two well-known names ROADMAP.md calls out ("start"/"done"), and only + /// when the enclosing node itself has a state/action keyword, fall back to a hardcoded, + /// narrowly-scoped last resort: look the name up directly among + /// Actions::Action's own direct children in the stdlib. This last-resort branch + /// exists because this codebase implements no general implicit-generalization/default- + /// supertype inference (a bare state def VehicleStates; with no explicit :> + /// clause never gets an automatic Supertype edge to States::StateAction/ + /// Actions::Action the way the real SysML v2 spec implies) — it is a deliberate, + /// explicitly-scoped simplification, not a general inherited-member resolver, and must not + /// be reused for any other reference kind or name. + /// + private bool TryResolveInheritedActionMember( + IReadOnlyList namespaceStack, + string name, + out string resolvedName) + { + resolvedName = string.Empty; + + if (namespaceStack.Count == 0) + { + return false; + } + + var enclosingNode = _symbolTable.Lookup(string.Join("::", namespaceStack)); + if (enclosingNode is null) + { + return false; + } + + var typingEdge = enclosingNode.ResolvedEdges.FirstOrDefault(e => e.Kind == SysmlEdgeKind.Typing); + if (typingEdge is not null) + { + var typeNode = _symbolTable.Lookup(typingEdge.TargetQualifiedName); + if (typeNode is not null) + { + var viaTyping = FindMemberInTypeHierarchy(typeNode, name, new HashSet()); + if (viaTyping?.QualifiedName is { Length: > 0 } viaTypingQualifiedName) + { + resolvedName = viaTypingQualifiedName; + return true; + } + } + } + + var viaAncestor = FindMemberInAncestorChain(enclosingNode, name, new HashSet()); + if (viaAncestor?.QualifiedName is { Length: > 0 } viaAncestorQualifiedName) + { + resolvedName = viaAncestorQualifiedName; + return true; + } + + // Last resort: only for the two well-known inherited pseudostate/action members named in + // ROADMAP.md, and only when the enclosing node itself has a state/action keyword, so this + // simplification cannot silently misfire for unrelated names or node kinds. + if (name is not ("start" or "done")) + { + return false; + } + + var isStateOrActionKeyword = enclosingNode switch + { + SysmlFeatureNode { FeatureKeyword: "state" or "action" } => true, + SysmlDefinitionNode { DefinitionKeyword: "state def" or "action def" } => true, + _ => false, + }; + + if (!isStateOrActionKeyword) + { + return false; + } + + var actionDefinition = _symbolTable.Lookup("Actions::Action"); + var fallback = actionDefinition?.Children.FirstOrDefault(c => c.Name == name); + if (fallback?.QualifiedName is not { Length: > 0 } fallbackQualifiedName) + { + return false; + } + + resolvedName = fallbackQualifiedName; + return true; + } + /// /// Resolves a dotted feature chain (e.g. engine.fuelPort, /// rearAxle.leftHalfAxle.axleToWheelPort) to an instance-relative qualified path for diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs index 312ca430..3b74a3be 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -520,4 +520,184 @@ public void StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingTo Assert.Contains(boxes, b => b.Label == "b1"); Assert.Contains(boxes, b => b.Label == "b2"); } + + /// + /// A pseudostate-sourced initial transition (first start then X;) makes the initial + /// marker's arrow land on the semantically-resolved target, not the first-declared state — + /// confirmed here with declaration order deliberately chosen so the two disagree. + /// + [Fact] + public void StateTransitionView_BuildLayout_PseudostateSourceTransition_MarksResolvedTargetNotFirstDeclared() + { + // Arrange: "b" is declared first, but "first start then b" is not present — instead + // "start" (an inherited pseudostate feature, never declared as its own state box) targets + // "b" while "a" is declared first. This confirms the marker follows the resolved target + // ("b") rather than the first-declared state ("a"). + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlTransitionNode { Source = "start", Target = "b" }, + new SysmlFeatureNode { Name = "a", QualifiedName = "P::M::a", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "P::M::b", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "a", Target = "b", Guard = null } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the initial marker's arrow terminates at "b"'s top edge (Y), not "a"'s — using Y + // rather than X since a linear a->b chain may share a column (same X) under the layered + // placement algorithm, but "a" (flow source) and "b" (flow target) never share the same Y. + var badge = Assert.Single(layout.Nodes.OfType()); + var bBox = layout.Nodes.OfType().Single(b => b.Keyword == "state" && b.Label == "b"); + var aBox = layout.Nodes.OfType().Single(b => b.Keyword == "state" && b.Label == "a"); + var markerArrow = layout.Nodes.OfType() + .Single(l => l.TargetEnd == EndMarkerStyle.FilledArrow && l.Waypoints.Count == 2 && + Math.Abs(l.Waypoints[0].X - badge.CentreX) < 0.01); + var arrowEndY = markerArrow.Waypoints[^1].Y; + Assert.Equal(bBox.Y, arrowEndY, precision: 3); + Assert.NotEqual(aBox.Y, arrowEndY, precision: 3); + } + + /// + /// No state box is ever labelled "start" — a pseudostate-sourced transition's source + /// must be excluded from ordinary state-box rendering (it would otherwise be synthesized as + /// an "additional state referenced only by a transition endpoint"). + /// + [Fact] + public void StateTransitionView_BuildLayout_PseudostateSourceTransition_NoSpuriousBox() + { + // Arrange + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlTransitionNode { Source = "start", Target = "off" }, + new SysmlFeatureNode { Name = "off", QualifiedName = "P::M::off", FeatureKeyword = "state" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one state box ("off"), never a "start" box. + var stateBoxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Single(stateBoxes); + Assert.DoesNotContain(stateBoxes, b => b.Label == "start"); + } + + /// + /// The entryActionMember (entryTransitionMember)* shape's implicit source (a named + /// entry-action feature, e.g. entry action initial; then off;) is likewise excluded + /// from ordinary state-box rendering, and the initial marker still resolves to the correct + /// target via the entry action's name. + /// + [Fact] + public void StateTransitionView_BuildLayout_EntryActionSourceTransition_NoSpuriousBoxUsesResolvedTarget() + { + // Arrange + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "initial", QualifiedName = "P::M::initial", FeatureKeyword = "entry" }, + new SysmlFeatureNode { Name = "a", QualifiedName = "P::M::a", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "off", QualifiedName = "P::M::off", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "initial", Target = "off" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: two state boxes ("a", "off"), no "initial" box, and the initial marker arrow + // lands on "off" (the entry action's declared target) rather than "a" (first declared). + var stateBoxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Equal(2, stateBoxes.Count); + Assert.DoesNotContain(stateBoxes, b => b.Label == "initial"); + + var badge = Assert.Single(layout.Nodes.OfType()); + var offBox = stateBoxes.Single(b => b.Label == "off"); + var markerArrow = layout.Nodes.OfType() + .Single(l => l.TargetEnd == EndMarkerStyle.FilledArrow && l.Waypoints.Count == 2 && + Math.Abs(l.Waypoints[0].X - badge.CentreX) < 0.01); + var arrowEndX = markerArrow.Waypoints[^1].X; + Assert.InRange(arrowEndX, offBox.X, offBox.X + offBox.Width); + } + + /// + /// Regression guard: when no pseudostate-sourced transition exists, the initial marker's + /// arrow still lands on the first-declared state — the pre-existing heuristic — exactly as + /// before this fix (protects every pre-existing passing test in this file, and the gallery's + /// 03-elevator-state.sysml, which uses only guarded transition first idle if ... + /// then ...; chains with no pseudostate source at all). + /// + [Fact] + public void StateTransitionView_BuildLayout_NoExplicitInitialTransition_FallsBackToFirstDeclared() + { + // Arrange: ordinary declared-state-to-state transitions only, no "start"/entry source. + var strategy = new StateTransitionViewLayoutStrategy(); + var machine = new SysmlDefinitionNode + { + Name = "M", + QualifiedName = "P::M", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::M::a", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "P::M::b", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "a", Target = "b", Guard = "t" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::M"] = machine } + }; + var context = new ViewContext("StateTransition", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: the initial marker's arrow lands on "a" (the first-declared state). + var badge = Assert.Single(layout.Nodes.OfType()); + var aBox = layout.Nodes.OfType().Single(b => b.Keyword == "state" && b.Label == "a"); + var markerArrow = layout.Nodes.OfType() + .Single(l => l.TargetEnd == EndMarkerStyle.FilledArrow && l.Waypoints.Count == 2 && + Math.Abs(l.Waypoints[0].X - badge.CentreX) < 0.01); + var arrowEndX = markerArrow.Waypoints[^1].X; + Assert.InRange(arrowEndX, aBox.X, aBox.X + aBox.Width); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs index 466b28bf..899bb325 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs @@ -164,4 +164,75 @@ public async Task Binding_OmgCorpusFixture_ResolvesBindingEdgesViaImplicitRedefi bindingEdges); Assert.Equal(2, bindingEdges.Count); } + + /// + /// The dedicated 25.Transitions/TransitionActions.sysml corpus fixture exercises all + /// three ROADMAP.md sub-problems together: the attached-transition state-body idiom + /// (state off; accept VehicleStartSignal then starting;), named/unnamed entry/do/exit + /// action features (state on { entry performSelfTest{...}; do action providePower{...}; + /// exit action applyParkingBrake{...}; }), and an inherited-pseudostate-feature initial + /// transition (first start then off;, where start is inherited from + /// Actions::Action since state def VehicleStates; declares no explicit + /// supertype). Before the fix this fixture produced 0 resolved states/transitions plus + /// "Unresolved reference" warnings for every never-registered name; the fixed counts below + /// were confirmed by directly exporting this fixture and inspecting the resulting AST + /// (see the developer report for the exact command). + /// + [Fact] + public async Task Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions() + { + var omgRoot = FindOmgModelsRoot(); + var file = Path.Combine(omgRoot, "training", "25.Transitions", "TransitionActions.sysml"); + Assert.True(File.Exists(file), $"Expected fixture not found: {file}"); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([file], stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, + d => d.Message.Contains("Unresolved reference: 'start'", StringComparison.Ordinal) || + d.Message.Contains("Unresolved reference: 'off'", StringComparison.Ordinal) || + d.Message.Contains("Unresolved reference: 'starting'", StringComparison.Ordinal) || + d.Message.Contains("Unresolved reference: 'on'", StringComparison.Ordinal)); + + var vehicleStates = (SysmlFeatureNode)result.Workspace!.Declarations["'Transition Actions'::vehicleStates"]; + + // 3 declared state-keyword children (off, starting, on); vehicleStates itself is the + // enclosing state usage, not counted among "states of the machine". + var declaredStates = vehicleStates.Children + .OfType() + .Where(f => f.FeatureKeyword == "state") + .ToList(); + Assert.Equal(3, declaredStates.Count); + Assert.Equal(["off", "starting", "on"], declaredStates.Select(s => s.Name)); + + // 4 transitions: start->off (pseudostate/initial), off->starting, starting->on, on->off — + // all fully resolved (each has a Transition ResolvedEdge, none unresolved). + var transitions = vehicleStates.Children.OfType().ToList(); + Assert.Equal(4, transitions.Count); + Assert.All(transitions, t => Assert.Contains(t.ResolvedEdges, e => e.Kind == SysmlEdgeKind.Transition)); + + var transitionEdges = transitions + .SelectMany(t => t.ResolvedEdges) + .Where(e => e.Kind == SysmlEdgeKind.Transition) + .Select(e => (Source: e.SourceQualifiedName ?? string.Empty, Target: e.TargetQualifiedName)) + .ToList(); + Assert.Contains(("Actions::Action::start", "'Transition Actions'::vehicleStates::off"), transitionEdges); + Assert.Contains( + ("'Transition Actions'::vehicleStates::off", "'Transition Actions'::vehicleStates::starting"), + transitionEdges); + Assert.Contains( + ("'Transition Actions'::vehicleStates::starting", "'Transition Actions'::vehicleStates::on"), + transitionEdges); + Assert.Contains( + ("'Transition Actions'::vehicleStates::on", "'Transition Actions'::vehicleStates::off"), + transitionEdges); + + // The "on" state's entry/do/exit action features are all registered: the entry action is + // the fixture's unnamed reference-subsetting form (Name null), do/exit are named. + var onState = declaredStates.Single(s => s.Name == "on"); + var actionFeatures = onState.Children.OfType().ToList(); + Assert.Contains(actionFeatures, f => f.FeatureKeyword == "entry" && f.Name is null); + Assert.Contains(actionFeatures, f => f.FeatureKeyword == "do" && f.Name == "providePower"); + Assert.Contains(actionFeatures, f => f.FeatureKeyword == "exit" && f.Name == "applyParkingBrake"); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index bf01f36c..f880f956 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -2759,12 +2759,16 @@ state def Behavior { } /// - /// A transition with an implied/omitted Source (an accept ... then target; - /// form with no preceding state to walk from) should produce no Transition edge — - /// a documented limitation of this unit, not a crash or a partial edge. + /// A previously-broken attached-transition shape: state off; accept Signal via + /// requestPort then off; is the stateBodyItem: (sourceSuccessionMember)? + /// behaviorUsageMember (targetTransitionUsageMember)* grammar alternative, whose + /// transition's Source is implicitly the immediately preceding state off; + /// usage. Before the attached-transition fix this silently dropped both the state and the + /// transition (ANTLR's default VisitChildren keeps only the last child); now both + /// are preserved and the transition resolves to a genuine self-loop edge. /// [Fact] - public async Task WorkspaceLoader_LoadAsync_TransitionImpliedSource_ProducesNoEdge() + public async Task WorkspaceLoader_LoadAsync_AttachedTransitionAfterState_ResolvesSelfLoopEdge() { // Arrange var tempFile = Path.GetTempFileName() + ".sysml"; @@ -2786,6 +2790,50 @@ accept Signal via requestPort 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.Transition && + e.SourceQualifiedName == "P::Behavior::off" && + e.TargetQualifiedName == "P::Behavior::off"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A transition attached after a genuinely unnamed/anonymous preceding usage (an anonymous + /// action;, which VisitActionUsage intentionally returns + /// for) has no name to serve as the attached transition's implicit Source. This is + /// the one remaining documented limitation: no crash, no partial edge, just nothing + /// recorded for that body item. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_TransitionImpliedSource_ProducesNoEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + item def Signal; + state def Behavior { + port requestPort; + action; + accept Signal via requestPort + then off; + state off; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + // Assert Assert.NotNull(result.Workspace); Assert.DoesNotContain(result.Workspace!.Index.AllEdges, @@ -2797,6 +2845,255 @@ accept Signal via requestPort } } + /// + /// Two attached transitions after the same preceding state usage (repeated + /// targetTransitionUsageMember) should both be captured, both with the preceding + /// usage's name as their implicit Source. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_MultipleAttachedTransitionsAfterState_CapturesAll() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + item def Sig1; + item def Sig2; + state def Behavior { + state a; + accept Sig1 then b; + accept Sig2 then c; + state b; + state c; + } + } + """, 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.Transition && + e.SourceQualifiedName == "P::Behavior::a" && + e.TargetQualifiedName == "P::Behavior::b"); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Transition && + e.SourceQualifiedName == "P::Behavior::a" && + e.TargetQualifiedName == "P::Behavior::c"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// The entryActionMember (entryTransitionMember)* attached-transition shape (e.g. + /// entry action initial; then off;) should capture both the named entry-action + /// feature and its attached transition, whose implicit Source is the entry action's + /// declared name. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_EntryActionWithAttachedTransition_CapturesEntryFeatureAndTransition() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + state def Behavior { + entry action initial; + then off; + state off; + } + } + """, 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.Transition && + e.SourceQualifiedName == "P::Behavior::initial" && + e.TargetQualifiedName == "P::Behavior::off"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// The OMG spec's Annex A.7-preferred style — a named entry action declared once, then + /// referenced from a separate explicit transition statement (rather than an + /// attached entryTransitionMember) — should register a resolvable feature: no + /// "Unresolved reference: 'initial'" diagnostic should be produced. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_NamedEntryAction_RegistersResolvableFeature() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + state def Behavior { + entry action initial; + state off; + transition initial then off; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.DoesNotContain(result.Diagnostics, + d => d.Message.Contains("Unresolved reference: 'initial'", StringComparison.Ordinal)); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Transition && + e.SourceQualifiedName == "P::Behavior::initial" && + e.TargetQualifiedName == "P::Behavior::off"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// The unnamed reference-subsetting form of an entry action (e.g. + /// entry performSelfTest;, which subsets/references an existing behavior rather + /// than declaring a new named feature) should still register a feature node (with + /// Name null) and must not throw. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_UnnamedEntryActionReferenceForm_NoNameNoCrash() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + action performSelfTest; + state def Behavior { + state on { + entry performSelfTest; + } + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// VisitStateUsage must record a Typing edge for a state usage's explicit + /// feature typing (e.g. state usage : X { ... }) — previously dropped entirely, + /// unlike every other usage kind's BuildUsageNode handling. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_StateUsageWithExplicitTyping_RecordsTypingEdge() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + state def X; + state usage : X { + first start then y; + state 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.Typing && + e.SourceQualifiedName == "P::usage" && + e.TargetQualifiedName == "P::X"); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A transition's implicit initial-pseudostate Source (first start then y;) + /// must resolve start to the real stdlib member every state definition/usage + /// inherits from Actions::Action (action start: Action :>> startShot), + /// via ReferenceResolver.TryResolveInheritedActionMember's narrow fallback — even + /// though the user's own state def X; declares no explicit supertype at all (this + /// codebase implements no general implicit-generalization/default-supertype inference). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_TransitionSourceStartFeature_ResolvesToStdlibActionMember() + { + // Arrange + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package P { + state def X; + state usage : X { + first start then y; + state y; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert + Assert.NotNull(result.Workspace); + Assert.DoesNotContain(result.Diagnostics, + d => d.Message.Contains("Unresolved reference: 'start'", StringComparison.Ordinal)); + Assert.Contains(result.Workspace!.Index.AllEdges, + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Transition && + e.SourceQualifiedName == "Actions::Action::start" && + e.TargetQualifiedName == "P::usage::y"); + } + finally + { + File.Delete(tempFile); + } + } + /// /// A pathological, self-referential supertype cycle (A :> B :> A) must not /// cause FindMemberInTypeHierarchy's recursive supertype walk to hang or stack