From 2906f57001851ff8fd176c41faed8db79305b95b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:00:05 +0000 Subject: [PATCH 01/12] feat(migrate): write composite-state entry and exit points as junctions, forks and joins A UML State.connectionPoint pseudostate is written as a junction of its state (a fork or join when its transitions each start, or come from, a different orthogonal region), reached by path, so the runtime runs the state's entry behavior before an entry point's outgoing transition and the transition into an exit point before the state's exit behavior. Shapes with no faithful form are refused with the shape named. The runtime exits and runs a compound transition's effects segment by segment (UML 14.2.3.8.4), so an effect out of a pseudostate inside a state runs before that state's exit. An empty transition effect keeps its braces. Co-Authored-By: jason.han --- ...composite-state-connection-points.added.md | 1 + ...pound-transition-exits-by-segment.fixed.md | 1 + .../empty-transition-effect-braces.fixed.md | 1 + .../design/precise-semantics-alignment.md | 2 +- docs/project/spec-compliance.md | 1 + docs/reference/sysml-v1-migration.md | 17 +- .../robustness_junction_exit_route_test.go | 118 +++++++ internal/exec/runtime/state_executor.go | 2 +- internal/exec/runtime/state_route.go | 105 ++++++- ...ns_before_owner_exit.declared.trace.golden | 38 +++ ...om_regions_before_owner_exit.expected.json | 20 ++ ...ions_before_owner_exit.seed-1.trace.golden | 38 +++ ..._exit_from_regions_before_owner_exit.sysml | 32 ++ ...rom_regions_before_owner_exit.trace.golden | 38 +++ ..._entry_skips_default_initial.expected.json | 9 + ...junction_entry_skips_default_initial.sysml | 32 ++ ...n_entry_skips_default_initial.trace.golden | 58 ++++ ...xit_effect_before_owner_exit.expected.json | 9 + ...nction_exit_effect_before_owner_exit.sysml | 31 ++ ...exit_effect_before_owner_exit.trace.golden | 33 ++ internal/translate/migrate/migrate.go | 8 + internal/translate/migrate/states.go | 297 +++++++++++++++++- internal/translate/migrate/writer.go | 21 +- tests/migrate/migrate_test.go | 2 +- tests/migrate/states_test.go | 199 ++++++++++++ .../xmi/station_points.golden.report.txt | 130 ++++++++ .../testdata/xmi/station_points.golden.sysml | 164 ++++++++++ tests/migrate/testdata/xmi/station_points.xmi | 243 ++++++++++++++ ...e_nested_pseudostate_qualified_ends.golden | 41 +++ ...te_nested_pseudostate_qualified_ends.sysml | 25 ++ 30 files changed, 1684 insertions(+), 32 deletions(-) create mode 100644 changes/unreleased/composite-state-connection-points.added.md create mode 100644 changes/unreleased/compound-transition-exits-by-segment.fixed.md create mode 100644 changes/unreleased/empty-transition-effect-braces.fixed.md create mode 100644 internal/exec/runtime/robustness_junction_exit_route_test.go create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden create mode 100644 tests/migrate/testdata/xmi/station_points.golden.report.txt create mode 100644 tests/migrate/testdata/xmi/station_points.golden.sysml create mode 100644 tests/migrate/testdata/xmi/station_points.xmi create mode 100644 tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.golden create mode 100644 tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.sysml diff --git a/changes/unreleased/composite-state-connection-points.added.md b/changes/unreleased/composite-state-connection-points.added.md new file mode 100644 index 0000000000..b7b7ef67ea --- /dev/null +++ b/changes/unreleased/composite-state-connection-points.added.md @@ -0,0 +1 @@ +- **The SysML v1 migrator writes a composite state's entry and exit points.** A UML `State.connectionPoint` pseudostate, on a nested composite state or on one with orthogonal regions, is a `junction` of the state, reached by path (`then Work::start;`, `first Work::leave then Idle;`), so the runtime runs the state's entry behavior before the entry point's outgoing transition and the transition into the exit point before the state's exit behavior, the order UML and PSSM give connection points. An entry point whose transitions each start a region of an orthogonal state is a `fork`, an exit point its regions reach from each side a `join`, and an entry point no transition leaves is the state's default entry. An entry point leading straight to an exit point of the same state, a route from a connection point on into a history pseudostate, and the other shapes with no faithful form are refused with the shape named in the report; before, every connection point on a state and every transition through it was unmapped. The OMG PSSM test suite's connection points, which the report refused wholesale, migrate under this rule, and the migrated suite validates with no syntax errors. diff --git a/changes/unreleased/compound-transition-exits-by-segment.fixed.md b/changes/unreleased/compound-transition-exits-by-segment.fixed.md new file mode 100644 index 0000000000..a71eaa7ef8 --- /dev/null +++ b/changes/unreleased/compound-transition-exits-by-segment.fixed.md @@ -0,0 +1 @@ +- **A compound transition through a pseudostate inside a composite state exits and runs its effects segment by segment.** A transition into a junction of a composite state, continued by the junction's outgoing transition out of the state, now exits the source, runs the first effect, exits the composite state, then runs the second effect and enters the target, as UML 2.5.1 §14.2.3.8.4 orders the segments; before, every exit ran before any effect. A join of the state its orthogonal regions leave through runs each region's exit and effect, then the state's exit, then the outgoing transition; a route ending at a terminate action or a history pseudostate keeps its effects in that order. diff --git a/changes/unreleased/empty-transition-effect-braces.fixed.md b/changes/unreleased/empty-transition-effect-braces.fixed.md new file mode 100644 index 0000000000..dac69ba1a9 --- /dev/null +++ b/changes/unreleased/empty-transition-effect-braces.fixed.md @@ -0,0 +1 @@ +- **The SysML v1 migrator writes a transition effect with no body as `do action effect { }`.** An effect activity with no nodes was written `do action effect;`, which ended the transition clause before its `then`, a syntax error in the migrated notation; the braces are now kept so the `then` still belongs to the transition. diff --git a/docs/internals/design/precise-semantics-alignment.md b/docs/internals/design/precise-semantics-alignment.md index e30683bd22..ce29e363ee 100644 --- a/docs/internals/design/precise-semantics-alignment.md +++ b/docs/internals/design/precise-semantics-alignment.md @@ -1476,7 +1476,7 @@ which supersede the hand count this section was first written with — the moves | Deferrable trigger | `defer Sig;` — this project's extension | extension | | Fork, join, junction, choice, shallow and deep history pseudostates | State-body `fork`/`join`/`junction`/`choice`/`history`/`deep history` — this project's extensions | extension | | Terminate pseudostate | A terminate action usage in the region, `action t terminate;`, that a transition ends at with `then t` (§7.18.3; SM38) | standard | -| Entry point, exit point (connection points and connection point references) | none | no spelling | +| Entry point, exit point (connection points and connection point references) | none in standard notation. The migrator's reading, established since this table was drawn: a composite state's entry or exit point is a `junction` of that state (a `fork`/`join` when its transitions each start, or come from, a different orthogonal region), reached by path — `then Work::start;`, `first Work::leave then Idle;` — so the runtime runs the state's entry behavior before the junction's outgoing transition, and the transition into the junction before the state's exit behavior, the order UML §14.2.3.4.5 and PSSM §8.5 give connection points; see [sysml-v1-migration.md](../../reference/sysml-v1-migration.md#behaviors). The referee's classifier still counts the construct here, so the table below is unchanged until the referee is rewired onto the migrator | no spelling (referee); extension (migrator) | | Local transition, internal transition | none (SM36, SM37) | no spelling | | State machine generalization: extended regions, redefined transitions | none | no spelling | | Entry, exit or do behavior with parameters (reading the triggering event's data) | none: the notation binds event data on the transition (`accept d : Data`), never on an `entry`/`exit`/`do` action | no translation | diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 5d4560b241..6fce72f69a 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -806,6 +806,7 @@ checked after the result is bound is not a form the runtime offers, and none is | Junction pseudostates: the route through a junction is resolved statically, before the incoming transition fires and before any effect runs; every outgoing guard is read against the data as it then stands, several enabled branches are the transition choice point at the junction (`ChoiceTaken` drawn and recorded only as the transition fires, after the region order among several candidates and the transition's own guard read again, enumerated by `explore`, replayed by a seed; a candidate another region's reaction disarms draws nothing; no branch guard is read again — the route beyond each enabled branch, through any further junction, is settled with the transition — so a branch enabled at selection is taken along it though another region's effect since changed what its guards read; the draw is made as the move begins, so a replay refused at a choice beyond the junction undoes the draw, its note and its `ChoiceTaken` with the move), the unguarded branches are the default when no guard holds, and no enabled branch leaves the compound transition unenabled; a history's default transition through such a junction draws and records the same way | `state_route.go` resolveRoute, followOut, enabledBranches (later guards read in a preview that is undone), junctionDraw, travel, travelResolving, settleDraws, pickBranch; `state_executor.go` fireTransition, defaultHistoryRoute | `state_junction_pseudostate.sysml`, `state_completion_through_pseudostate.sysml`, `state_junction_several_enabled_branches.sysml`, `state_junction_drawn_as_its_transition_fires.sysml`, `state_junction_guards_read_once.sysml`, `state_junction_beyond_a_draw_read_once.sysml`, `state_history_default_through_junction.sysml` (+ trace goldens, `.check.expected.json`), `explore_test.go:TestExploreStaticJunctionBranches`, `:TestExploreJunctionDrawnAsTransitionFires`, `:TestExploreHistoryDefaultThroughJunction`, `replay_test.go:TestReplayRefusedChoiceUndoesTheJunctionDrawBeforeIt`, `:TestReplayRefusedChoiceUndoesTheHistoryDefaultsJunctionDraw`, `robustness_test.go:region_pseudostate_without_satisfied_guard` | ✅ Faithful (static, unlike a choice; which of several enabled branches is taken is open, as at a choice) | | Fork pseudostates: the branches enter one composite state's orthogonal regions, one target per region, at least two, none guarded or triggered (UML 2.5.1 §14.2.3.5 Pseudostates: the transitions outgoing from a fork "must not have a guard or a trigger"), bypassing the initial state of each region they name — a target may lie below a region's own substates, the region being the one it lies in transitively, and the branch enters every state on the way down; a region the fork leaves out starts at its own initial state, and a region without one may be entered by a fork's branch alone — a machine with another way into the composite state that would start such a region by default (a transition to the state itself, to another of its regions or its history, the machine's entry naming it, or another fork — nested in one of its regions, or above it — passing through) is refused when lowered. Entering through a fork exits the source configuration down to the ancestor the source and the composite share — every region of the composite, in declaration order, when the source lies inside them, the composite itself staying active — then the first branch in region order runs its effect and enters the states still on the way down, and every region enters in declaration order, each branch's effect before its target — a region the fork leaves out never brings the composite in ahead of a branch, however early it is declared — the effects reading and writing the attributes of the state declaring the fork, as a transition leaving one of its substates does; branches ending at `done` complete the composite, or the machine, as an ordinary entry does. A fork's static footprint covers the regions it leaves to start by default, so the checker's reduction keeps their entry behaviors' reads, writes and completion dependent on what other units do | `lower/fork_plan.go` `ForkPlan`, `planFork`, `forkOwner`, `enclosingRegion`, `ForkStarted`, `checkForkOnlyRegion`, `defaultEntryInto`, `defaultStart`, `forkStartsByDefault`; `lower/state_footprint.go` `stateFootprintBuilder.pseudostate` (fork case), `entersRegion`; `lower/state_graph.go` `ownTransitionEffects`; `state_executor.go` `fireForkTransition`, `leaveForFork`, `exitRegionsOf`, `completeIfDone`; `state_region_entry.go` `forkEntry`, `enterForkBranches`, `enterRegion`, `enterLazily` | `lower/fork_plan_test.go` (`TestToStateGraph_ForkEntersRegionsWithoutInitial`, `:TestToStateGraph_ForkBranchEffectsOwnedByDeclaringState`, `:TestToStateGraph_ForkKeepsRegionInitialApart`, `:TestToStateGraph_ForkBranchTargetsNestedState`, `:TestToStateGraph_ForkBranchesNestedInOneRegionFail`, `:TestToStateGraph_RegionWithoutInitialOrForkFails`, `:TestToStateGraph_ForkOnlyRegionEnteredByDefaultFails`, `:TestToStateGraph_ForkOnlyRegionEnteredByAnEnclosingEntryFails`, `:TestToStateGraph_ForkOnlyRegionOmittedByAnotherForkFails`, `:TestToStateGraph_ForkOnlyRegionEnteredByANestedForkFails`, `:TestToStateGraph_ForkOnlyRegionKeepsExplicitEntries`, `:TestToStateGraph_ForkShapeRejected`), `lower/state_footprint_test.go:TestTransitionFootprintsCoverForkOmittedRegions`, `state_fork_join_pseudostate.sysml` + trace golden, `state_fork_enters_regions_without_initial.sysml`, `state_fork_in_composite_enters_parallel_substate.sysml`, `state_fork_through_inactive_ancestors.sysml`, `state_fork_within_active_ancestor.sysml`, `state_fork_within_active_region.sysml`, `state_fork_completes_owner.sysml`, `state_fork_completes_nested_owner.sysml`, `state_fork_enters_nested_region_states.sysml`, `state_fork_enters_nested_parallel_state.sysml`, `state_fork_omitted_region_declared_first.sysml`, `state_fork_from_within_owner_regions.sysml`, `state_fork_from_within_nested_owner_regions.sysml` (+ trace goldens), `state_fork_branch_effect_owns_state_attribute.sysml`, `fork_join_test.go:TestForkBypassesTargetedRegionInitials`, `robustness_test.go:fork_branches_share_region`, `:fork_leaves_a_region_without_a_way_in`, `:fork_only_region_entered_by_default`, `:fork_branch_with_a_trigger`, `:nested_fork_starts_an_outer_region_by_default` | ✅ Faithful | | Join pseudostates | `state_executor.go:782` fireJoinTransition, `:827` joinSources (declaration order) | `pseudostate_test.go:TestJoinWaitsForEveryBranch`, `fork_join_test.go:TestForkJoinVisitOrderIsDeterministic` | ✅ Faithful | +| A compound transition through a pseudostate declared inside a composite state exits and runs its effects segment by segment (UML 2.5.1 §14.2.3.8.4, the segments' behaviors "executed in sequence", each after the exits of the states its segment leaves; PSSM §8.5 on exit points): a transition `first Inner accept Sig do { A } then Owner::x;` into a junction `x` of `Owner`, continued by `first Owner::x do { B } then Out;`, exits `Inner`, runs `A`, exits `Owner`, runs `B`, then enters `Out` — not every exit first. The segments' effects are grouped by segment (`routeEffect.segment`); a segment leaving from a pseudostate of a state exits that state's descendants the move leaves and the states up to that segment's boundary before its effects, and a segment from the machine's body exits what the move has still to leave. Into a join of `Owner` from its orthogonal regions, each region's transition exits its source and runs its effect (the regions in an open order), then `Owner` exits, then the join's outgoing transition runs. A route that ends at a terminate action, or at a history pseudostate, keeps its effects in that order. This is the runtime form the SysML v1 migrator writes a composite state's exit point in (`docs/reference/sysml-v1-migration.md`, *Pseudostate exitPoint on a composite State*); a transition into a junction of a composite state from outside runs the state's entry behavior before the junction's outgoing transition and the target's entries, which the migrator's entry point form relies on | `state_route.go` travelResolving, leaveAlong, leftBySegment, segmentBoundary, vertexState; `state_executor.go` terminateAlong, terminateAt, moveToHistory (effects passed through) | `state_junction_exit_effect_before_owner_exit.sysml` + `.trace.golden`, `state_join_exit_from_regions_before_owner_exit.sysml` + `.trace.golden` (`admissible` orders), `state_junction_entry_skips_default_initial.sysml` + `.trace.golden` (an entry through a junction runs the state's entry behavior, skips the region's `entry; then`, and history restores the substate); `robustness_junction_exit_route_test.go:TestRuntimeRobustnessJunctionExitRoute` (a junction left through with no outgoing transition, one whose outgoing transition ends at a shallow history, one whose every guard is false: typed errors naming the junction); `tests/migrate/states_test.go:TestCompositeStateConnectionPointsKeepTheUMLOrder` (the migrated `station_points.xmi` run end to end) | ✅ Faithful | | History pseudostates (shallow and deep). A region is recorded per region, in the state it was left in, so a region left by a transition that started inside its own composite state's region is restored to that composite state and its inner configuration rather than to the region's initial state; a region left with no active state at all has nothing to restore; a region or body left at `done` completed and leaves no history. A shallow or deep history with nothing recorded takes its own outgoing transition when it has one and otherwise performs the owning state's default entry, through its `entry` transition, as a plain transition into the composite would; an owner with no entry transition either is the typed `ErrHistoryWithoutEntry` naming the history and its owner | `parser/behavior.go` parseStateMember (`history ;`, `shallow history ;`, `deep history ;`); `state_executor.go` fireHistoryTransition, `:historyEntry`, `:historyRecorded` (an empty history, read by `state_route.go` `resolveRoute` too when settling the default transition), `:hasDefaultEntry`, `:deepestRecorded`, `exitState` (records the configuration left), `:recordChildHistory`, `:recordRegionHistory`, `:forgetRegionHistory`, `errors.go` `ErrHistoryWithoutEntry`, `state_region_transition.go` `leaveRegion`, `exitRegionTo`, `lower/state_graph.go` PseudostateOwner | `tests/parser/testdata/parse/state_history.golden`, `parser/state_notation_test.go:TestHistoryPseudostateParsing`, `lower/state_notation_test.go:TestToStateGraph_HistoryNotation`, `state_shallow_history.sysml`, `state_deep_history.sysml`, `state_history_revisit.sysml` + trace golden, `state_deep_history_region_composite.sysml`, `history_test.go:TestShallowHistoryRestoresLastSubstate`, `:TestDeepHistoryRestoresInnermostSubstate`, `:TestHistoryRestoresOrthogonalRegions`, `:TestDeepHistoryRestoresBelowRegion`, `:TestDeepHistoryRestoresARegionLeftFromInsideItsCompositeState`, `:TestHistoryTakesDefaultTransitionWhenUnvisited`, `:TestHistoryOverACompletedConfigurationIsADefaultEntry`, `state_history_empty_default_entry`, `state_deep_history_empty_default_entry`, `state_history_after_completion_default_entry`, `state_history_after_completion_default_transition` (+ trace goldens), `robustness_test.go:history_outside_composite_state`, `:history_without_record_default_or_entry` | ✅ Faithful | | Composite state with regions entered by a plain transition | `state_executor.go` transitionToInto (keeps the region configuration entering it just built) | `history_test.go:TestHistoryRestoresOrthogonalRegions` | ✅ Faithful | | Leaving a composite state exits only its own regions | `state_executor.go` exitState (scoped to `CompositeStates[state]`) | `history_test.go:TestExitingNestedRegionsKeepsSiblingRegions` | ✅ Faithful | diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index c012328f28..0929322710 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -177,6 +177,9 @@ returned over the service yet. | Pseudostate fork, join | `fork x;` / `join x;` — the transitions out of a fork enter the states of several regions of a `parallel` state, those into a join leave them | mapped | | Pseudostate shallowHistory, deepHistory | `history x;` / `deep history x;` in the composite state; a transition targeting it re-enters the substate (the innermost substates) active when the state was last left, the history's own outgoing transition being its default | mapped | | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | +| Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | +| Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired | mapped | +| Entry point leading straight to an exit point of the same state; an entry point whose outgoing transitions leave the state, or several of which start the same region; an exit point with several incoming transitions from the same region (or from outside its regions), or with several outgoing transitions; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | @@ -454,7 +457,19 @@ chains, `fork`/`join` to enter and leave the regions of an orthogonal state, `hi history` to re-enter what was active when the state was last left. An entry or exit point of a state machine is a `state` of its `state def` whose own transition continues into the machine, and a submachine state's connection point references address them by path, -`then Cell::warmStart;` / `first Cell::spent then Idle;`. An internal transition is a self +`then Cell::warmStart;` / `first Cell::spent then Idle;`. An entry or exit point of a composite +state (UML `State.connectionPoint`) is a `junction` of that state — its transient node, so a +transition in from outside, `then Work::start;`, runs the state's entry behavior, then the +junction's own transition and the target's entries in the same step, and a transition out, +`first Work::leave then Idle;`, runs the inner transition's exits and effect, the state's exit +behavior, then the outgoing effect and the target's entry — the order UML 2.5.1 §14.2.3.4.5 and +PSSM give connection points. An entry point whose transitions each start a region of an +orthogonal state is a `fork`, an exit point its regions reach from each side a `join`; an entry +point no transition leaves is the state's default entry, and the transition is written to the +state. An entry point that leads straight to an exit point of the same state, so the state is +crossed without settling in it, is refused: the runtime would run neither its entry nor its exit +behavior; so is a route from a connection point on into a history pseudostate, and any point +whose transitions do not form one of the shapes above. An internal transition is a self transition, faithful when re-entering the state is not observable (no entry, exit, do or substates) and reported otherwise; one written with no target stays in its source, one that targets another vertex or leaves a pseudostate is refused, and one without a trigger is a diff --git a/internal/exec/runtime/robustness_junction_exit_route_test.go b/internal/exec/runtime/robustness_junction_exit_route_test.go new file mode 100644 index 0000000000..bb77519439 --- /dev/null +++ b/internal/exec/runtime/robustness_junction_exit_route_test.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "strings" + "testing" + "time" +) + +// TestRuntimeRobustnessJunctionExitRoute: a compound transition that leaves a +// composite state through a junction of its own fails with a typed error, never +// a hang or a panic, when the route past the junction cannot be taken. +func TestRuntimeRobustnessJunctionExitRoute(t *testing.T) { + t.Run("junction_of_the_left_state_with_no_outgoing_transition", testJunctionOfTheLeftStateWithNoOutgoingTransition) + t.Run("junction_of_the_left_state_leading_into_history", testJunctionOfTheLeftStateLeadingIntoHistory) + t.Run("junction_of_the_left_state_whose_every_branch_is_closed", testJunctionOfTheLeftStateWhoseEveryBranchIsClosed) +} + +// runRouteToError drives one Go through the machine and returns the error the +// run ends with, failing the test if the run hangs or succeeds. +func runRouteToError(t *testing.T, exec *StateExecutor) error { + t.Helper() + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + exec.SendSignal("Go", nil) + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected the run to fail at the junction the composite state is left through") + } + return err + case <-time.After(10 * time.Second): + t.Fatal("RunToCompletion hung on a junction the composite state is left through") + } + return nil +} + +// testJunctionOfTheLeftStateWithNoOutgoingTransition: the nested source's exit +// leads to a junction of its owner that no transition leaves; the error names it. +func testJunctionOfTheLeftStateWithNoOutgoingTransition(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then S1; + state S1 { + entry; then A; + state A; + junction XP; + transition first A accept Go then XP; + } + state S2; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP has no outgoing transitions") { + t.Errorf("expected the error to name the junction, got %v", err) + } +} + +// testJunctionOfTheLeftStateLeadingIntoHistory: a junction the owner is left +// through cannot lead on into a sibling's history; the refusal names both. +func testJunctionOfTheLeftStateLeadingIntoHistory(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then S1; + state S1 { + entry; then A; + state A; + junction XP; + transition first A accept Go then XP; + } + state S2 { + entry; then B; + state B; + history H; + } + transition first S1::XP then S2::H; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP: a transition into shallow history H is not supported") { + t.Errorf("expected the refusal to name the junction and the history, got %v", err) + } +} + +// testJunctionOfTheLeftStateWhoseEveryBranchIsClosed: a junction's guards are +// read before the compound transition fires; when none holds the run fails at +// the junction before any exit or effect runs. +func testJunctionOfTheLeftStateWhoseEveryBranchIsClosed(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + attribute def Go; + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + entry; then S1; + state S1 { + exit action { assign log := log + "S1(exit);"; } + entry; then A; + state A { exit action { assign log := log + "A(exit);"; } } + junction XP; + transition first A accept Go do assign x := 2 then XP; + } + state S2; + transition first S1::XP if x == 1 then S2; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP: no guard evaluated to true") { + t.Errorf("expected the error to name the junction, got %v", err) + } + if log := FormatValue(exec.StateData()["log"]); log != `""` { + t.Errorf("log is %s, want empty: nothing is left when no branch out of the junction holds", log) + } +} diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 5cfaad98eb..f4d1b0240e 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -2416,7 +2416,7 @@ func (e *StateExecutor) moveToHistory(trans *lower.Transition, currentState *ast return err } if r.terminate != nil { - return e.terminateAt(trans, fromName, r, e.descendantChain(below, e.graph.TerminateOwner[r.terminate])) + return e.terminateAt(trans, fromName, r, r.effects(e.graph), e.descendantChain(below, e.graph.TerminateOwner[r.terminate])) } if err := e.runEffects(r.effects(e.graph), e.descendantChain(below, r.target)); err != nil { return err diff --git a/internal/exec/runtime/state_route.go b/internal/exec/runtime/state_route.go index 66251168fc..899ee567e6 100644 --- a/internal/exec/runtime/state_route.go +++ b/internal/exec/runtime/state_route.go @@ -711,11 +711,11 @@ func (e *StateExecutor) travelResolving(trans *lower.Transition, from *ast.State if err != nil { return err } - if err := e.exitAhead(ends.certainExits()); err != nil { + certainExits, certainEntries := ends.certainExits(), ends.certainEntries() + if _, err := e.leaveAlong(r, certainExits, certainEntries, nil); err != nil { return err } - certainEntries := ends.certainEntries() - if err := e.runEffects(r.effects(e.graph), certainEntries); err != nil { + if err := e.exitAhead(certainExits); err != nil { return err } if err := e.enterOwnerOf(r.choice, certainEntries); err != nil { @@ -726,29 +726,116 @@ func (e *StateExecutor) travelResolving(trans *lower.Transition, from *ast.State return err } } + var leaving, entering []*ast.StateNode + if r.terminate != nil { + leaving = e.expandExits(e.terminateExits(from, trans, r.terminate)) + entering = e.terminateEntries(from, trans, r.terminate) + } else { + leaving, entering = e.expandExits(exits(r.target)), enters(r.target) + } + effects, err := e.leaveAlong(r, leaving, entering, r.segments[len(r.segments)-1]) + if err != nil { + return err + } e.noteFired(r.segments...) if r.terminate != nil { - return e.terminateAlong(trans, from, r) + return e.terminateAlong(trans, from, r, effects) + } + return move(effects, r.target) +} + +// leaveAlong runs the segments before upto, exiting what each has left before its effects +// (UML 14.2.3.8.4), and returns the effects from upto on, which run after the move's exits. +func (e *StateExecutor) leaveAlong(r route, leaving, entering []*ast.StateNode, upto *lower.Transition) ([]routeEffect, error) { + effects := r.effects(e.graph) + var left []*ast.StateNode + for _, seg := range r.segments { + if seg == upto { + break + } + left = append(left, e.leftBySegment(leaving, seg)...) + n := 0 + for n < len(effects) && effects[n].segment == seg { + n++ + } + if n == 0 { + continue + } + if err := e.exitAhead(left); err != nil { + return nil, err + } + left = nil + if err := e.runEffects(effects[:n], entering); err != nil { + return nil, err + } + effects = effects[n:] + } + return effects, nil +} + +// leftBySegment lists, among the states the move leaves, those seg leaves: the ones from its +// source up to its boundary and their contents; from the machine's body, all still to exit. +func (e *StateExecutor) leftBySegment(leaving []*ast.StateNode, seg *lower.Transition) []*ast.StateNode { + source := e.vertexState(seg.Source) + if source == nil { + return leaving + } + boundary := e.segmentBoundary(seg) + if boundary != nil && e.isBelowOrEqual(boundary, source) { + return nil + } + top := source + for e.graph.ParentState[top] != boundary && e.graph.ParentState[top] != nil { + top = e.graph.ParentState[top] + } + var left []*ast.StateNode + for _, state := range leaving { + if e.isBelowOrEqual(state, top) { + left = append(left, state) + } } - return move(r.effects(e.graph), r.target) + return left +} + +// segmentBoundary is the state seg stays inside of: the LCA of its ends (a pseudostate standing +// in its declaring state), or the parent of a source that encloses the target; nil is the body. +func (e *StateExecutor) segmentBoundary(seg *lower.Transition) *ast.StateNode { + source, target := e.vertexState(seg.Source), e.vertexState(seg.Target) + if declared, isState := seg.Source.(*ast.StateNode); isState && e.encloses(declared, target) { + return e.graph.ParentState[declared] + } + return e.getLCA(source, target) +} + +// vertexState is the state a transition end lies in; nil for the machine's body. +func (e *StateExecutor) vertexState(end ast.Node) *ast.StateNode { + switch v := end.(type) { + case *ast.StateNode: + return v + case *ast.PseudostateNode: + return e.graph.PseudostateOwner[v] + case *ast.Usage: + return e.graph.TerminateOwner[v] + } + return nil } // terminateAlong finishes a transition at the terminate action its route reaches // (SysML v2 §7.18.3): the states the move leaves are exited and the ones down to // the action's owner entered, as for a state beside it, then the machine ends. -func (e *StateExecutor) terminateAlong(trans *lower.Transition, from *ast.StateNode, r route) error { +func (e *StateExecutor) terminateAlong(trans *lower.Transition, from *ast.StateNode, r route, effects []routeEffect) error { if err := e.exitStates(e.terminateExits(from, trans, r.terminate)); err != nil { return err } - return e.terminateAt(trans, StateVertexName(from), r, e.terminateEntries(from, trans, r.terminate)) + return e.terminateAt(trans, StateVertexName(from), r, effects, e.terminateEntries(from, trans, r.terminate)) } // terminateAt ends the machine's performance at the terminate action r reaches, // the move's exits done: the effects run entering the chain down to the action's // owner, then no further state is exited, no exit behavior runs, and the do // behaviors still under way are abandoned where they stand. -func (e *StateExecutor) terminateAt(trans *lower.Transition, fromName string, r route, entering []*ast.StateNode) error { - if err := e.runEffects(r.effects(e.graph), entering); err != nil { +func (e *StateExecutor) terminateAt(trans *lower.Transition, fromName string, r route, effects []routeEffect, entering []*ast.StateNode) error { + if err := e.runEffects(effects, entering); err != nil { return err } if err := e.enterAhead(entering); err != nil { diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json new file mode 100644 index 0000000000..1f949cd8e9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);"} + } + }, + { + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "B1(exit);T22(effect);A1(exit);T12(effect);S1(exit);T3(effect);S2(entry);"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml new file mode 100644 index 0000000000..8596309f7e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml @@ -0,0 +1,32 @@ +// A join inside a composite state stands for an exit point reached from each +// orthogonal region: each incoming segment exits its region and runs its +// effect, then the owner's exit action runs, then the outgoing segment fires. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then S1; + state S1 parallel { + exit action { assign log := log + "S1(exit);"; } + state R1 { + entry; then A1; + state A1 { exit action { assign log := log + "A1(exit);"; } } + transition first A1 accept Go do assign log := log + "T12(effect);" then XP; + } + state R2 { + entry; then B1; + state B1 { exit action { assign log := log + "B1(exit);"; } } + transition first B1 accept Go do assign log := log + "T22(effect);" then XP; + } + join XP; + } + transition first S1::XP do assign log := log + "T3(effect);" then S2; + state S2 { + entry action { assign log := log + "S2(entry);"; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json new file mode 100644 index 0000000000..bd291ba8a5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Back"}, {"signal": "Again"}], + "finalState": "A2", + "outputs": { + "log": {"type": "String", "value": "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);A2(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml new file mode 100644 index 0000000000..50a611bd22 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml @@ -0,0 +1,32 @@ +// A junction inside a composite state stands for an entry point: entering +// through it runs the owner's entry action, then the junction's outgoing +// effect, then the target's entry, and the owner's default initial does not +// fire. A later entry through history restores the state the junction reached. +package Test { + private import ScalarValues::*; + + attribute def Go; attribute def Back; attribute def Again; + + state Machine { + attribute log : String = ""; + + entry; then S0; + state S0; + state S1 { + entry action { assign log := log + "S1(entry);"; } + exit action { assign log := log + "S1(exit);"; } + then A1; + junction EP; + history H; + state A1 { entry action { assign log := log + "A1(entry);"; } } + state A2 { + entry action { assign log := log + "A2(entry);"; } + exit action { assign log := log + "A2(exit);"; } + } + transition first EP do assign log := log + "EP(effect);" then A2; + } + transition first S0 accept Go do assign log := log + "T1(effect);" then S1::EP; + transition first S1 accept Back do assign log := log + "T2(effect);" then S0; + transition first S0 accept Again do assign log := log + "T3(effect);" then S1::H; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden new file mode 100644 index 0000000000..5f95e3187a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden @@ -0,0 +1,58 @@ +exit: S0 +stmt assign log + eval feature log -> "" + eval literal "T1(effect);" -> "T1(effect);" + eval operator + -> "T1(effect);" +enter: S1 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);" + eval literal "S1(entry);" -> "S1(entry);" + eval operator + -> "T1(effect);S1(entry);" +stmt assign log + eval feature log -> "T1(effect);S1(entry);" + eval literal "EP(effect);" -> "EP(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);" +enter: A2 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);" + eval literal "A2(entry);" -> "A2(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);" +transition: S0 -> A2 (event: accept Go) +exit: A2 (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);" + eval literal "A2(exit);" -> "A2(exit);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);" +stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);" + eval literal "T2(effect);" -> "T2(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);" +enter: S0 +transition: A2 -> S0 (event: accept Back) +exit: S0 +stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);" +enter: S1 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);" + eval literal "S1(entry);" -> "S1(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);" +enter: A2 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);" + eval literal "A2(entry);" -> "A2(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);A2(entry);" +transition: S0 -> A2 (event: accept Again) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json new file mode 100644 index 0000000000..272ab7cbb5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);S2(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml new file mode 100644 index 0000000000..dc4c1ef769 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml @@ -0,0 +1,31 @@ +// A junction inside a composite state stands for an exit point: the transition +// into it from a nested state runs its effect after the nested exits and +// before the owner's exit action, then the outgoing segment's effect runs. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then S1; + state S1 { + exit action { assign log := log + "S1(exit);"; } + entry; then S11; + state S11 { + exit action { assign log := log + "S11(exit);"; } + entry; then S111; + state S111 { + exit action { assign log := log + "S111(exit);"; } + } + } + junction XP; + transition first S111 accept Go do assign log := log + "T12(effect);" then XP; + } + transition first S1::XP do assign log := log + "T3(effect);" then S2; + state S2 { + entry action { assign log := log + "S2(entry);"; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden new file mode 100644 index 0000000000..6cbaca1253 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden @@ -0,0 +1,33 @@ +exit: S111 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "S111(exit);" -> "S111(exit);" + eval operator + -> "S111(exit);" +exit: S11 (exit action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);" + eval literal "S11(exit);" -> "S11(exit);" + eval operator + -> "S111(exit);S11(exit);" +stmt assign log + eval feature log -> "S111(exit);S11(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "S111(exit);S11(exit);T12(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);" +stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);S2(entry);" +transition: S111 -> S2 (event: accept Go) diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index c1c8148241..aea76fd38a 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -81,6 +81,9 @@ func FromModel(name string, model *sysmlv1.Model) *Result { indexed: map[string]int{}, regionUsed: map[*sysmlv1.Element]map[string]bool{}, vertexNames: map[*sysmlv1.Element]string{}, + points: map[*sysmlv1.Element]pointForm{}, + incoming: map[*sysmlv1.Element][]*sysmlv1.Element{}, + outgoing: map[*sysmlv1.Element][]*sysmlv1.Element{}, instant: map[*sysmlv1.Element]map[*sysmlv1.Element]instantValue{}, self: "this", lanes: map[*sysmlv1.Element]*lanes{}, @@ -245,6 +248,11 @@ type migration struct { regionUsed map[*sysmlv1.Element]map[string]bool // vertexNames gives the v2 name of every vertex a state machine writes. vertexNames map[*sysmlv1.Element]string + // points says how each connection point of a composite state is written. + points map[*sysmlv1.Element]pointForm + // incoming and outgoing list the transitions into and out of each vertex + // of the machines named so far. + incoming, outgoing map[*sysmlv1.Element][]*sysmlv1.Element // instant names, per state machine, the TimeInstantValue attribute each // absolute time event its transitions accept is written as. instant map[*sysmlv1.Element]map[*sysmlv1.Element]instantValue diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index fc00f69eb4..102338f8ca 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -43,6 +43,7 @@ func (m *migration) nameMachine(sm *sysmlv1.Element) map[string]bool { } used := inheritedStateNamesSet() m.regionUsed[sm] = used + m.indexTransitions(sm) for _, cp := range sm.Owned("connectionPoint") { m.nameVertex(cp, sm, used) } @@ -50,6 +51,26 @@ func (m *migration) nameMachine(sm *sysmlv1.Element) map[string]bool { return used } +// indexTransitions lists the transitions into and out of every vertex of a +// machine, so a connection point's shape can be read before it is written. +func (m *migration) indexTransitions(sm *sysmlv1.Element) { + var walk func(e *sysmlv1.Element) + walk = func(e *sysmlv1.Element) { + for _, c := range e.Children { + if c.Role == "transition" { + if src := m.model.Ref(c, "source"); src != nil { + m.outgoing[src] = append(m.outgoing[src], c) + } + if tgt := m.model.Ref(c, "target"); tgt != nil { + m.incoming[tgt] = append(m.incoming[tgt], c) + } + } + walk(c) + } + } + walk(sm) +} + // nameRegions names the vertices of the regions of a state machine or state: // one region shares its owner's body, several become the sub-states of one // parallel state, each with a body of its own; owner is the element whose body @@ -77,11 +98,163 @@ func (m *migration) nameRegions(regions []*sysmlv1.Element, owner *sysmlv1.Eleme func (m *migration) nameRegion(r, owner *sysmlv1.Element, used map[string]bool) { m.regionUsed[r] = used for _, v := range r.Owned("subvertex") { - m.nameVertex(v, owner, used) if v.Type == "State" { - m.nameRegions(m.populatedRegions(v), v, inheritedStateNamesSet()) + m.nameVertex(v, owner, used) + inner := inheritedStateNamesSet() + m.namePoints(v, inner) + m.nameRegions(m.populatedRegions(v), v, inner) + continue + } + if pointOwner(v) != nil && pointOwner(v).Type == "State" { + // A tool that lists a state's connection point among its region's vertices. + continue + } + m.nameVertex(v, owner, used) + } +} + +// namePoints settles how each connection point of composite state v is written +// and names those written as members of its body, whose names used lists. +func (m *migration) namePoints(v *sysmlv1.Element, used map[string]bool) { + for _, cp := range m.connectionPoints(v) { + f := m.statePointForm(cp, v) + m.points[cp] = f + if f.kw != "" { + m.nameVertex(cp, v, used) + } + } +} + +// connectionPoints lists the entry and exit points of composite state v: those +// it owns, and those a tool listed among the vertices of its regions. +func (m *migration) connectionPoints(v *sysmlv1.Element) []*sysmlv1.Element { + points := v.Owned("connectionPoint") + for _, r := range v.Owned("region") { + for _, sv := range r.Owned("subvertex") { + if k := pseudoKind(sv); k == "entryPoint" || k == "exitPoint" { + points = append(points, sv) + } + } + } + return points +} + +// pointOwner is the state or state machine an entry or exit point belongs to, +// through the region a tool may have listed it in; nil for another vertex. +func pointOwner(v *sysmlv1.Element) *sysmlv1.Element { + if k := pseudoKind(v); k != "entryPoint" && k != "exitPoint" { + return nil + } + owner := v.Parent + if owner != nil && owner.Type == "Region" { + owner = owner.Parent + } + return owner +} + +// pointForm says how a composite state's entry or exit point is written: as +// the pseudostate kw of the state's body, or not at all. +type pointForm struct { + // kw is junction, fork or join; "" when the point is written as no member. + kw string + // note is what the report says of a point that is written. + note string + // defaultEntry marks an entry point no transition leaves: a transition to it + // enters its state by the state's default entry, so it is written to the state. + defaultEntry bool + // why says why a point has no v2 form; "" when it has one. + why string +} + +// statePointForm settles how connection point v of composite state owner is written from the +// transitions through it: a junction, a fork starting several regions, or a join they leave by. +func (m *migration) statePointForm(v, owner *sysmlv1.Element) pointForm { + if pseudoKind(v) == "entryPoint" { + return m.entryPointForm(v, owner) + } + return m.exitPointForm(v, owner) +} + +func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { + out := m.outgoing[v] + if len(out) == 0 { + return pointForm{defaultEntry: true, note: "no transition leaves the entry point, so entering through it enters " + describe(owner) + " by its default entry; a transition to it is written to the state"} + } + for _, t := range out { + tgt := m.model.Ref(t, "target") + if tgt != nil && pseudoKind(tgt) == "exitPoint" && pointOwner(tgt) == owner { + return pointForm{why: describe(t) + " leads from the entry point straight to the exit point " + describe(tgt) + " of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior"} + } + } + regions := m.regionsCrossed(out, owner, "target") + if len(out) < 2 || len(regions) < 2 { + return pointForm{kw: "junction", note: "written as a junction of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction"} + } + if len(regions) < len(out) { + return pointForm{why: "the entry point starts several regions of " + describe(owner) + ", as a fork does, but two of its outgoing transitions enter the same region"} + } + for _, t := range out { + if why := m.forkBranchWhy(t, m.model.Ref(t, "target")); why != "" { + return pointForm{why: "the entry point starts several regions of " + describe(owner) + ", as a fork does, but " + describe(t) + " " + why} + } + } + return pointForm{kw: "fork", note: "written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches"} +} + +func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { + in := m.incoming[v] + regions := m.regionsCrossed(in, owner, "source") + if len(in) < 2 || len(regions) < 2 { + return pointForm{kw: "junction", note: "written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it"} + } + if len(regions) < len(in) { + return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but two of its incoming transitions leave the same region"} + } + for _, t := range in { + if src := m.model.Ref(t, "source"); src != nil && src.Type != "State" { + return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " + describe(t) + " leaves " + describe(src) + ", a " + kindOf(src) + " rather than a state"} + } + } + return pointForm{kw: "join", note: "written as a join of its state, which its regions leave through together; the transitions into the join run, then the state's exit behavior, then the transition leaving it"} +} + +// regionsCrossed lists the distinct regions of owner the given ends of the +// transitions lie in; an end outside owner counts as none. +func (m *migration) regionsCrossed(transitions []*sysmlv1.Element, owner *sysmlv1.Element, role string) []*sysmlv1.Element { + var regions []*sysmlv1.Element + for _, t := range transitions { + if r := regionWithin(m.model.Ref(t, role), owner); r != nil && !slices.Contains(regions, r) { + regions = append(regions, r) } } + return regions +} + +// regionWithin is the region of owner that holds vertex v, however deep; nil +// when v lies outside owner. +func regionWithin(v, owner *sysmlv1.Element) *sysmlv1.Element { + for cur := v; cur != nil && cur.Parent != nil; cur = cur.Parent { + if cur.Parent == owner && cur.Type == "Region" { + return cur + } + } + return nil +} + +// forkBranchWhy says why transition t into tgt cannot be a fork's branch, which +// enters a state with no trigger or guard of its own; "" when it can. +func (m *migration) forkBranchWhy(t, tgt *sysmlv1.Element) string { + switch { + case tgt == nil: + return "lacks a target" + case tgt.Type != "State": + return "enters " + describe(tgt) + ", a " + kindOf(tgt) + " rather than a state" + case len(t.Owned("trigger")) > 0: + return "has a trigger" + case firstOwned(t, "guard") != nil: + return "has a guard" + } + return "" } // populatedRegions returns the regions of a machine or state that hold a @@ -398,6 +571,10 @@ func (s *stateRegion) vertex(v *sysmlv1.Element) { case "terminate": s.m.add(v, Approximated, "done", "a terminate pseudostate ends the machine; a transition to it is written to done, which ends its region") case "entryPoint", "exitPoint": + if pointOwner(v).Type == "State" { + // Written in the body of the state it belongs to. + return + } s.m.connectionPoint(v) default: s.m.unmapped(v, "no v2 form for a "+pseudoKind(v)+" pseudostate") @@ -409,12 +586,12 @@ func (s *stateRegion) vertex(v *sysmlv1.Element) { } } -// connectionPoint writes an entry or exit point as a state of the state def: entered at the entry -// point's state, and leaving through the exit point's state completes the submachine state's transition. +// connectionPoint writes an entry or exit point of a state machine as a state of the state def: entered +// at the entry point's state, and leaving through the exit point's state completes the submachine state's transition. func (m *migration) connectionPoint(v *sysmlv1.Element) { name, ok := m.vertexNames[v] if !ok { - m.unmapped(v, "a "+pseudoKind(v)+" owned by a "+v.Parent.Type+" is not written; only a state machine's connection points are") + m.unmapped(v, "a "+pseudoKind(v)+" owned by a "+v.Parent.Type+" is not written; only a state machine's or a state's connection points are") return } m.w.line(stateKw + writeName(name) + ";") @@ -425,6 +602,38 @@ func (m *migration) connectionPoint(v *sysmlv1.Element) { m.add(v, Mapped, name, "written as a state; a transition entering a submachine state through the entry point enters this state") } +// statePoints writes the connection points of composite state v in its body, in +// the form namePoints settled on; it reports how many members were written. +func (m *migration) statePoints(v *sysmlv1.Element) int { + written := 0 + for _, cp := range m.connectionPoints(v) { + f := m.points[cp] + switch { + case f.why != "": + m.unmapped(cp, f.why) + case f.defaultEntry: + m.add(cp, Mapped, m.vertexNames[v], f.note) + default: + name := writeName(m.vertexNames[cp]) + m.w.line(f.kw + " " + name + ";") + m.add(cp, Mapped, name, f.note) + written++ + } + } + return written +} + +// writtenPoints counts the connection points of v written as members of its body. +func (m *migration) writtenPoints(v *sysmlv1.Element) int { + n := 0 + for _, cp := range m.connectionPoints(v) { + if m.points[cp].kw != "" { + n++ + } + } + return n +} + // state writes a state, typed by its submachine's state def when it has one, // with its entry, do and exit actions, deferrals and regions. func (s *stateRegion) state(v *sysmlv1.Element) { @@ -450,8 +659,10 @@ func (s *stateRegion) state(v *sysmlv1.Element) { regions := s.m.populatedRegions(v) entry, do, exit := s.m.stateBehavior(v, "entry"), s.m.stateBehavior(v, "doActivity"), s.m.stateBehavior(v, "exit") inv := firstOwned(v, "stateInvariant") - if entry == nil && do == nil && exit == nil && inv == nil && len(regions) == 0 && len(defers) == 0 { + points := s.m.connectionPoints(v) + if entry == nil && do == nil && exit == nil && inv == nil && len(regions) == 0 && len(defers) == 0 && s.m.writtenPoints(v) == 0 { s.m.w.line(head + ";") + s.m.statePoints(v) return } s.m.w.block(head, func() { @@ -468,6 +679,9 @@ func (s *stateRegion) state(v *sysmlv1.Element) { if exit != nil { s.m.inlineBehavior("exit action", exit, v) } + if len(points) > 0 { + s.m.statePoints(v) + } } if len(regions) == 0 { between() @@ -580,11 +794,11 @@ func (m *migration) inlineBehavior(kw string, b, owner *sysmlv1.Element) bool { note = joinNotes(note, "its parameters take no value: "+why) } } - line := kw + " : " + m.ref(b, owner) + ";" if len(ins) > 0 { - line = kw + " : " + m.ref(b, owner) + " { " + strings.Join(ins, "; ") + "; }" + m.w.line(kw + " : " + m.ref(b, owner) + " { " + strings.Join(ins, "; ") + "; }") + } else { + m.w.block(kw+" : "+m.ref(b, owner), func() {}) } - m.w.line(line) m.downgrade(b, note) return true } @@ -697,12 +911,31 @@ func (s *stateRegion) target(t, v *sysmlv1.Element) (string, bool) { case "terminate": return "done", true case "exitPoint": + if s.m.points[v].why != "" { + return "", false + } if p, ok := s.endpoint(t, v, "target"); ok { return p, true } + if pointOwner(v).Type == "State" { + return "", false + } s.m.add(t, Approximated, "", "the exit point "+describe(v)+" belongs to another machine; the transition is written to done") return "done", true - case "choice", "junction", "fork", "join", "shallowHistory", "deepHistory", "entryPoint": + case "entryPoint": + f := s.m.points[v] + if f.why != "" { + return "", false + } + if f.defaultEntry { + p, ok := s.endpoint(t, pointOwner(v), "target") + if ok { + s.m.add(t, Mapped, "", "written to "+p+": no transition leaves the entry point "+describe(v)+", so entering through it enters the state by its default entry") + } + return p, ok + } + return s.endpoint(t, v, "target") + case "choice", "junction", "fork", "join", "shallowHistory", "deepHistory": return s.endpoint(t, v, "target") } case "ConnectionPointReference": @@ -722,7 +955,12 @@ func (s *stateRegion) source(t, v *sysmlv1.Element) (string, bool) { return s.endpoint(t, v, "source") case "Pseudostate": switch pseudoKind(v) { - case "choice", "junction", "fork", "join", "shallowHistory", "deepHistory", "entryPoint", "exitPoint": + case "entryPoint", "exitPoint": + if s.m.points[v].why != "" { + return "", false + } + return s.endpoint(t, v, "source") + case "choice", "junction", "fork", "join", "shallowHistory", "deepHistory": return s.endpoint(t, v, "source") } case "ConnectionPointReference": @@ -731,6 +969,25 @@ func (s *stateRegion) source(t, v *sysmlv1.Element) (string, bool) { return "", false } +// noForm says why vertex v is no end for a transition: its connection point's refusal, +// else that it lies outside the machine or has no v2 form. +func (s *stateRegion) noForm(v *sysmlv1.Element) string { + if why := s.m.points[v].why; why != "" { + return " has no v2 form: " + why + } + return isA + kindOf(v) + outsideMachine +} + +// transient reports a pseudostate a compound transition passes through in one +// step, whose outgoing transitions the runtime follows on reaching it. +func transient(v *sysmlv1.Element) bool { + switch pseudoKind(v) { + case "junction", "choice", "entryPoint", "exitPoint": + return true + } + return false +} + // connection names the state of a submachine's state def a connection point // reference stands for, through the submachine state: `sub::point`. The // reference is reported once, where the first transition passes through it. @@ -802,15 +1059,19 @@ func (s *stateRegion) transition(t *sysmlv1.Element) { return } } + if transient(src) && (pseudoKind(tgt) == "shallowHistory" || pseudoKind(tgt) == "deepHistory") { + s.m.unmapped(t, "the runtime does not follow a transition from a "+pseudoKind(src)+" pseudostate on into the history pseudostate "+describe(tgt)) + return + } from, ok := s.source(t, src) if !ok { - s.m.unmapped(t, "the source "+describe(src)+isA+kindOf(src)+outsideMachine) + s.m.unmapped(t, "the source "+describe(src)+s.noForm(src)) return } to := from if !internal { if to, ok = s.target(t, tgt); !ok { - s.m.unmapped(t, "the target "+describe(tgt)+isA+kindOf(tgt)+outsideMachine) + s.m.unmapped(t, "the target "+describe(tgt)+s.noForm(tgt)) return } } @@ -968,14 +1229,16 @@ func (s *stateRegion) writeTransitionEffect(t, eff *sysmlv1.Element, accept acce } s.m.w.line(line) s.m.w.indented(func() { - if eff == nil { - s.m.w.block("do action", func() { s.m.w.line(accept.keeping) }) - } else { + s.m.w.braced(func() { + if eff == nil { + s.m.w.block("do action", func() { s.m.w.line(accept.keeping) }) + return + } saved, savedKeep := s.m.bound, s.m.keeping s.m.bound, s.m.keeping = accept.bound, accept.keeping s.m.inlineBehavior("do action", eff, t) s.m.bound, s.m.keeping = saved, savedKeep - } + }) s.m.w.line("then " + to + ";") }) } diff --git a/internal/translate/migrate/writer.go b/internal/translate/migrate/writer.go index 11ab0e4581..1cc900c986 100644 --- a/internal/translate/migrate/writer.go +++ b/internal/translate/migrate/writer.go @@ -8,6 +8,8 @@ import "strings" type writer struct { bufs []*strings.Builder indent int + // braces is the buffer depth whose empty blocks keep their braces; 0 for none. + braces int } func (w *writer) buf() *strings.Builder { @@ -35,9 +37,10 @@ func (w *writer) lines(ls []string) { } // block writes header with a brace-delimited body, or as `header;` when the -// body writes nothing. +// body writes nothing, unless braced asked for the braces to be kept. func (w *writer) block(header string, body func()) { w.buf() + depth := len(w.bufs) w.bufs = append(w.bufs, &strings.Builder{}) w.indent++ body() @@ -45,7 +48,11 @@ func (w *writer) block(header string, body func()) { inner := w.bufs[len(w.bufs)-1].String() w.bufs = w.bufs[:len(w.bufs)-1] if inner == "" { - w.line(header + ";") + if w.braces == depth { + w.line(header + " { }") + } else { + w.line(header + ";") + } return } w.line(header + " {") @@ -53,6 +60,16 @@ func (w *writer) block(header string, body func()) { w.line("}") } +// braced runs body with its blocks at the current depth keeping their braces when empty, +// for a clause a `;` would end, as a transition's do action that `then` must follow. +func (w *writer) braced(body func()) { + saved := w.braces + w.buf() + w.braces = len(w.bufs) + body() + w.braces = saved +} + // indented writes body one level deeper, for a clause continued on the next lines. func (w *writer) indented(body func()) { w.indent++ diff --git a/tests/migrate/migrate_test.go b/tests/migrate/migrate_test.go index e066d6ad8d..665d622afb 100644 --- a/tests/migrate/migrate_test.go +++ b/tests/migrate/migrate_test.go @@ -297,7 +297,7 @@ func TestRejectsNonXMI(t *testing.T) { // constructFixtures are the XMI documents under testdata/xmi that each exercise // one family of behavioral constructs; their notation and report are golden. -var constructFixtures = []string{"plant_states", "rig_interactions", "heater_receptions", "ported_calls"} +var constructFixtures = []string{"plant_states", "station_points", "rig_interactions", "heater_receptions", "ported_calls"} // migrateFixtureFile migrates testdata/xmi/.xmi. func migrateFixtureFile(t *testing.T, name string) *migrate.Result { diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 86cbe9ac0a..9b52e414f7 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -178,6 +178,163 @@ func TestStateMachineCrossRegionTransitionsAndPseudostates(t *testing.T) { } } +// testdata/xmi/station_points.xmi: entry and exit points owned by composite states — on a +// nested state, on a state with orthogonal regions, beside a default initial pseudostate +// and a shallow history — are written as junctions, a fork and a join of their state; an +// entry point leading straight to an exit point of its state is refused. Every entry, exit +// and effect behavior appends a two-digit code to trace, so a run pins the UML order: +// 11 Work entry, 12 Work exit, 13 Prep entry, 14 Run entry, 15 Run exit, 17 Fast entry, +// 21 Deep→Fast, 22 Fast→Out, 23 Start→Run, 24 Run→Leave, 25 Out→Prep, 31 Sync entry, +// 32 Sync exit, 33/34 A1 entry/exit, 35/36 B1 entry/exit, 37 A1→Gather, 38 B1→Gather, +// 41 Idle→Start, 42 Idle→Deep, 43 Leave→Idle, 44 Work→Idle, 46 Gather→Idle. +func TestCompositeStateConnectionPointsKeepTheUMLOrder(t *testing.T) { + r := migrateFixtureFile(t, "station_points") + for _, line := range []string{ + "state Work {", + "junction Start;", + "junction Leave;", + "junction Deep;", + "junction Out;", + "history H;", + "transition first Work::Run::Deep", + "transition first Fast accept Back", + "then Work::Run::Out;", + "transition first Work::Start", + "transition first Run accept Finish", + "then Work::Leave;", + "transition first Work::Run::Out", + "fork Both;", + "join Gather;", + "transition first Sync::Both then A1;", + "transition first Sync::Both then B1;", + "then Sync::Gather;", + "then Work::Start;", + "transition first Idle accept Enter then Work;", + "then Work::Run::Deep;", + "transition first Idle accept Resume then Work::H;", + "transition first Work::Leave", + "transition first Idle accept Split then Sync::Both;", + "transition first Sync::Gather", + "/* not migrated: Pseudostate 'Through' — (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior */", + } { + wantLine(t, r.Notation, line) + } + if strings.Contains(string(r.Notation), "state Start") || strings.Contains(string(r.Notation), "then Through") { + t.Errorf("a connection point was written as a state, or a refused one was named:\n%s", r.Notation) + } + wantNote(t, r, "_start", migrate.Mapped, "written as a junction of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction") + wantNote(t, r, "_leave", migrate.Mapped, "written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it") + wantNote(t, r, "_deep", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_out", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_plain", migrate.Mapped, "no transition leaves the entry point, so entering through it enters 'Work' by its default entry; a transition to it is written to the state") + wantNote(t, r, "_tEnter", migrate.Mapped, "written to Work: no transition leaves the entry point 'Plain'") + wantNote(t, r, "_both", migrate.Mapped, "written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches") + wantNote(t, r, "_gather", migrate.Mapped, "written as a join of its state, which its regions leave through together; the transitions into the join run, then the state's exit behavior, then the transition leaving it") + wantNote(t, r, "_tGo", migrate.Mapped, "named by its path Work::Start") + wantNote(t, r, "_tDive", migrate.Mapped, "named by its path Work::Run::Deep") + wantNote(t, r, "_tBothA", migrate.Mapped, "named by its path Sync::Both") + wantNote(t, r, "_tAg", migrate.Mapped, "named by its path Sync::Gather") + wantNote(t, r, "_hist", migrate.Mapped, "written as a shallow history") + wantNote(t, r, "_through", migrate.Unmapped, "leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior") + wantNote(t, r, "_tThrough", migrate.Unmapped, "the source 'Through' has no v2 form") + wantNote(t, r, "_tSkip", migrate.Unmapped, "the target 'Through' has no v2 form") + + s := session(t, r) + trace := func(object, want string) { + t.Helper() + if out := meta(t, s, "%eval in "+object+" : trace"); strings.TrimSpace(out[strings.LastIndex(out, "=")+1:]) != want { + t.Errorf("trace of %s: want %s, got\n%s", object, want, out) + } + } + current := func(want string) { + t.Helper() + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: "+want) { + t.Errorf("want the current state %s:\n%s", want, out) + } + } + station := func() string { + t.Helper() + out := meta(t, s, "%instantiate Station") + _, id, ok := strings.Cut(out, "ID: ") + if !ok { + t.Fatalf("%%instantiate Station: %s", out) + } + id, _, _ = strings.Cut(id, "\n") + object := "#" + strings.TrimSpace(id) + meta(t, s, "%state Station::Cycle "+object) + return object + } + drive := func(signal, fires string) { + t.Helper() + if out := meta(t, s, "%send "+signal); !strings.Contains(out, "transition "+fires+" fires on it") { + t.Errorf("%%send %s: %s", signal, out) + } + meta(t, s, "%step") + } + + // Through the entry point Start: the effect into it, Work's entry, the + // transition out of it, then Run and its default Slow. Out through Leave + // from the nested Run: Run's exit, the transition into it, Work's exit, + // then the transition out of it. + one := station() + drive("Go", "Idle -> Start") + current("Slow") + trace(one, "41112314") + drive("Finish", "Run -> Leave") + current("Idle") + trace(one, "4111231415241243") + meta(t, s, "%stop") + + // Into the nested Run through its own entry point Deep: Work's entry, Run's + // entry, the transition out of Deep, then Fast rather than the default Slow. + // Out of Run through its exit point Out: the transition into it, Run's exit, + // then the transition out of it into Prep, still inside Work. + two := station() + drive("Dive", "Idle -> Deep") + current("Fast") + trace(two, "4211142117") + drive("Back", "Fast -> Out") + current("Prep") + trace(two, "421114211722152513") + meta(t, s, "%stop") + + // An entry point no transition leaves enters Work by its default Prep. After + // Work is left from Run, the shallow history beside the entry points brings + // Run back, with its default Slow. + three := station() + drive("Enter", "Idle -> Work") + current("Prep") + trace(three, "1113") + drive("Next", "Prep -> Run") + drive("Stop", "Work -> Idle") + trace(three, "111314151244") + drive("Resume", "Idle -> H") + current("Slow") + trace(three, "1113141512441114") + meta(t, s, "%stop") + + // The entry point Both of the orthogonal Sync starts both regions at once + // after Sync's entry; each region then leaves through the exit point Gather, + // which joins them: both exits and effects, Sync's exit, then the transition out. + four := station() + drive("Split", "Idle -> Both") + current("A1 | B1") + trace(four, "313335") + meta(t, s, "%step") + current("Idle") + trace(four, "313335343736383246") + meta(t, s, "%stop") + + // Entered plainly, Sync's regions start at A0 and B0 and reach Gather the same way. + five := station() + drive("Pair", "Idle -> Sync") + current("A0 | B0") + drive("Bump", "A0 -> A1 and transition B0 -> B1") + meta(t, s, "%step") + current("Idle") + trace(five, "313335343736383246") +} + // gateMachine has an empty region beside the one holding its states, and transitions // between Idle and the nested Busy::Inner across nesting levels. const gateMachine = ` @@ -435,3 +592,45 @@ func TestTargetlessInternalTransitionsStayInTheirSource(t *testing.T) { t.Errorf("the internal transition left Idle:\n%s", out) } } + +const emptyEffectMachine = ` + + + + + + + + + + + + + + + + ` + +const emptyEffectApplications = ` + ` + +// A transition whose effect activity has no nodes keeps its braces, `do action effect { }`, +// so the `then` clause that follows still belongs to the transition; the result parses and runs. +func TestEmptyTransitionEffectKeepsItsBraces(t *testing.T) { + r := migrateDocument(t, emptyEffectMachine, emptyEffectApplications) + wantLine(t, r.Notation, "do action effect { }\n") + wantLine(t, r.Notation, "then Busy;") + if strings.Contains(string(r.Notation), "do action effect;") { + t.Errorf("an empty effect ended the transition clause:\n%s", r.Notation) + } + s := session(t, r) + meta(t, s, "%instantiate Blank") + meta(t, s, "%state Blank::Blanking") + if out := meta(t, s, "%send Go"); !strings.Contains(out, "transition Idle -> Busy fires on it") { + t.Errorf("%%send Go: %s", out) + } + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: Busy") { + t.Errorf("the transition with the empty effect did not fire:\n%s", out) + } +} diff --git a/tests/migrate/testdata/xmi/station_points.golden.report.txt b/tests/migrate/testdata/xmi/station_points.golden.report.txt new file mode 100644 index 0000000000..a4a2b9a4dd --- /dev/null +++ b/tests/migrate/testdata/xmi/station_points.golden.report.txt @@ -0,0 +1,130 @@ +# SysML v1 to v2 migration report: station_points.xmi +# migrated 122 element(s): 92 mapped, 26 approximated, 4 unmapped (0 skipped as profile, library or notation-only content, 0 as model elements nothing refers to) + +## unmapped (4) +SignalEvent _skipEv (every trigger referring to the event is dropped: it belongs to a behavior that is not written, or to an initial transition) +Transition Station::Cycle::main:: _tSkip (the target 'Through' has no v2 form: (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior) +Pseudostate Station::Cycle::main::Work::Through _through ((_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior) +Transition Station::Cycle::main::Work::steps:: _tThrough (the source 'Through' has no v2 form: (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior) + +## approximated (26) +Class Station _station -> Station (a plain UML class without «Block» is written as a part def) +StateMachine Station::Cycle _cycle -> Station::Cycle (the classifier behavior is run by every object of Station as its usage cycle) +OpaqueBehavior Station::Cycle::main:::: _goEff -> Station::Cycle::unnamed::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main:::: _diveEff -> Station::Cycle::unnamed2::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main:::: _leaveEff -> Station::Cycle::unnamed3::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main:::: _stopEff -> Station::Cycle::unnamed4::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main:::: _gatherEff -> Station::Cycle::unnamed5::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync:: _syncEntry -> Station::Cycle::Sync::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync:: _syncExit -> Station::Cycle::Sync::unnamed2 (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::a:::: _agEff -> Station::Cycle::Sync::regions::a::unnamed::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::a::A1:: _a1Entry -> Station::Cycle::Sync::regions::a::A1::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::a::A1:: _a1Exit -> Station::Cycle::Sync::regions::a::A1::unnamed2 (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::b:::: _bgEff -> Station::Cycle::Sync::regions::b::unnamed::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::b::B1:: _b1Entry -> Station::Cycle::Sync::regions::b::B1::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Sync::b::B1:: _b1Exit -> Station::Cycle::Sync::regions::b::B1::unnamed2 (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work:: _workEntry -> Station::Cycle::Work::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work:: _workExit -> Station::Cycle::Work::unnamed2 (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps:::: _startEff -> Station::Cycle::Work::unnamed::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps:::: _finishEff -> Station::Cycle::Work::unnamed2::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps:::: _outEff -> Station::Cycle::Work::unnamed3::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Prep:: _prepEntry -> Station::Cycle::Work::Prep::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Run:: _runEntry -> Station::Cycle::Work::Run::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Run:: _runExit -> Station::Cycle::Work::Run::unnamed2 (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Run::pace:::: _deepEff -> Station::Cycle::Work::Run::unnamed::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Run::pace:::: _backEff -> Station::Cycle::Work::Run::unnamed2::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) +OpaqueBehavior Station::Cycle::main::Work::steps::Run::pace::Fast:: _fastEntry -> Station::Cycle::Work::Run::Fast::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) + +## mapped (92) +SignalEvent _backEv (written where a trigger refers to it, as accept Back) +SignalEvent _nextEv (written where a trigger refers to it, as accept Next) +SignalEvent _finishEv (written where a trigger refers to it, as accept Finish) +SignalEvent _bumpEv (written where a trigger refers to it, as accept Bump) +SignalEvent _goEv (written where a trigger refers to it, as accept Go) +SignalEvent _enterEv (written where a trigger refers to it, as accept Enter) +SignalEvent _diveEv (written where a trigger refers to it, as accept Dive) +SignalEvent _resumeEv (written where a trigger refers to it, as accept Resume) +SignalEvent _stopEv (written where a trigger refers to it, as accept Stop) +SignalEvent _pairEv (written where a trigger refers to it, as accept Pair) +SignalEvent _splitEv (written where a trigger refers to it, as accept Split) +Signal Back _back -> Back +Signal Bump _bump -> Bump +Signal Dive _dive -> Dive +Signal Enter _enter -> Enter +Signal Finish _finish -> Finish +Signal Go _go -> Go +Model Model _m (the root model's members are written at the top level) +Signal Next _next -> Next +Signal Pair _pair -> Pair +Signal Resume _resume -> Resume +Signal Skip _skip -> Skip +Signal Split _split -> Split +Region Station::Cycle::main _main (the one region is written as the body of its owner) +Pseudostate Station::Cycle::main:: _init0 (written as the entry of the region) +Transition Station::Cycle::main:: _t0 +Transition Station::Cycle::main:: _tGo (the target 'Start' lies in another region and is named by its path Work::Start) +Transition Station::Cycle::main:: _tEnter (written to Work: no transition leaves the entry point 'Plain', so entering through it enters the state by its default entry) +Transition Station::Cycle::main:: _tDive (the target 'Deep' lies in another region and is named by its path Work::Run::Deep) +Transition Station::Cycle::main:: _tResume (the target 'H' lies in another region and is named by its path Work::H) +Transition Station::Cycle::main:: _tLeave (the source 'Leave' lies in another region and is named by its path Work::Leave) +Transition Station::Cycle::main:: _tStop +Transition Station::Cycle::main:: _tPair +Transition Station::Cycle::main:: _tSplit (the target 'Both' lies in another region and is named by its path Sync::Both) +Transition Station::Cycle::main:: _tGather (the source 'Gather' lies in another region and is named by its path Sync::Gather) +Trigger Station::Cycle::main:::: _trGo +Trigger Station::Cycle::main:::: _trEnter +Trigger Station::Cycle::main:::: _trDive +Trigger Station::Cycle::main:::: _trResume +Trigger Station::Cycle::main:::: _trStop +Trigger Station::Cycle::main:::: _trPair +Trigger Station::Cycle::main:::: _trSplit +State Station::Cycle::main::Idle _idle -> Idle +State Station::Cycle::main::Sync _sync -> Sync +Pseudostate Station::Cycle::main::Sync::Both _both -> Both (written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches) +Pseudostate Station::Cycle::main::Sync::Gather _gather -> Gather (written as a join of its state, which its regions leave through together; the transitions into the join run, then the state's exit behavior, then the transition leaving it) +Region Station::Cycle::main::Sync::a _ra -> a (the one region is written as the body of its owner; an orthogonal region is written as a sub-state of the parallel state regions) +Pseudostate Station::Cycle::main::Sync::a:: _initA (written as the entry of the region) +Transition Station::Cycle::main::Sync::a:: _tAi +Transition Station::Cycle::main::Sync::a:: _tBothA (the source 'Both' lies in another region and is named by its path Sync::Both) +Transition Station::Cycle::main::Sync::a:: _tA +Transition Station::Cycle::main::Sync::a:: _tAg (the target 'Gather' lies in another region and is named by its path Sync::Gather) +Trigger Station::Cycle::main::Sync::a:::: _trA +State Station::Cycle::main::Sync::a::A0 _a0 -> A0 +State Station::Cycle::main::Sync::a::A1 _a1 -> A1 +Region Station::Cycle::main::Sync::b _rb -> b (the one region is written as the body of its owner; an orthogonal region is written as a sub-state of the parallel state regions) +Pseudostate Station::Cycle::main::Sync::b:: _initB (written as the entry of the region) +Transition Station::Cycle::main::Sync::b:: _tBi +Transition Station::Cycle::main::Sync::b:: _tBothB (the source 'Both' lies in another region and is named by its path Sync::Both) +Transition Station::Cycle::main::Sync::b:: _tB +Transition Station::Cycle::main::Sync::b:: _tBg (the target 'Gather' lies in another region and is named by its path Sync::Gather) +Trigger Station::Cycle::main::Sync::b:::: _trB +State Station::Cycle::main::Sync::b::B0 _b0 -> B0 +State Station::Cycle::main::Sync::b::B1 _b1 -> B1 +State Station::Cycle::main::Work _work -> Work +Pseudostate Station::Cycle::main::Work::Leave _leave -> Leave (written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it) +Pseudostate Station::Cycle::main::Work::Plain _plain -> Work (no transition leaves the entry point, so entering through it enters 'Work' by its default entry; a transition to it is written to the state) +Pseudostate Station::Cycle::main::Work::Start _start -> Start (written as a junction of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction) +Region Station::Cycle::main::Work::steps _steps (the one region is written as the body of its owner) +Pseudostate Station::Cycle::main::Work::steps:: _initW (written as the entry of the region) +Transition Station::Cycle::main::Work::steps:: _tWi +Transition Station::Cycle::main::Work::steps:: _tNext +Transition Station::Cycle::main::Work::steps:: _tStart (the source 'Start' lies in another region and is named by its path Work::Start) +Transition Station::Cycle::main::Work::steps:: _tFinish (the target 'Leave' lies in another region and is named by its path Work::Leave) +Transition Station::Cycle::main::Work::steps:: _tOut (the source 'Out' lies in another region and is named by its path Work::Run::Out) +Trigger Station::Cycle::main::Work::steps:::: _trNext +Trigger Station::Cycle::main::Work::steps:::: _trFinish +Pseudostate Station::Cycle::main::Work::steps::H _hist -> H (written as a shallow history, which re-enters the substate active when its state was last left) +State Station::Cycle::main::Work::steps::Prep _prep -> Prep +State Station::Cycle::main::Work::steps::Run _run -> Run +Pseudostate Station::Cycle::main::Work::steps::Run::Deep _deep -> Deep (written as a junction of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction) +Pseudostate Station::Cycle::main::Work::steps::Run::Out _out -> Out (written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it) +Region Station::Cycle::main::Work::steps::Run::pace _pace (the one region is written as the body of its owner) +Pseudostate Station::Cycle::main::Work::steps::Run::pace:: _initR (written as the entry of the region) +Transition Station::Cycle::main::Work::steps::Run::pace:: _tRi +Transition Station::Cycle::main::Work::steps::Run::pace:: _tDeep (the source 'Deep' lies in another region and is named by its path Work::Run::Deep) +Transition Station::Cycle::main::Work::steps::Run::pace:: _tBack (the target 'Out' lies in another region and is named by its path Work::Run::Out) +Trigger Station::Cycle::main::Work::steps::Run::pace:::: _trBack +State Station::Cycle::main::Work::steps::Run::pace::Fast _fast -> Fast +State Station::Cycle::main::Work::steps::Run::pace::Slow _slow -> Slow +Property Station::trace _trace -> Station::trace +Signal Stop _stop -> Stop diff --git a/tests/migrate/testdata/xmi/station_points.golden.sysml b/tests/migrate/testdata/xmi/station_points.golden.sysml new file mode 100644 index 0000000000..9ac55f2c3f --- /dev/null +++ b/tests/migrate/testdata/xmi/station_points.golden.sysml @@ -0,0 +1,164 @@ +item def Go; +item def Enter; +item def Dive; +item def Resume; +item def Skip; +item def Next; +item def Finish; +item def Back; +item def Stop; +item def Pair; +item def Split; +item def Bump; +part def Station { + attribute trace : ScalarValues::Integer default = 0; + state def Cycle { + entry; then Idle; + state Idle; + state Work { + entry action { + assign this.trace := this.trace * 100 + 11; + } + then Prep; + exit action { + assign this.trace := this.trace * 100 + 12; + } + junction Start; + /* not migrated: Pseudostate 'Through' — (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior */ + junction Leave; + state Prep { + entry action { + assign this.trace := this.trace * 100 + 13; + } + } + state Run { + entry action { + assign this.trace := this.trace * 100 + 14; + } + then Slow; + exit action { + assign this.trace := this.trace * 100 + 15; + } + junction Deep; + junction Out; + state Slow; + state Fast { + entry action { + assign this.trace := this.trace * 100 + 17; + } + } + transition first Work::Run::Deep + do action { + assign this.trace := this.trace * 100 + 21; + } + then Fast; + transition first Fast accept Back + do action { + assign this.trace := this.trace * 100 + 22; + } + then Work::Run::Out; + } + history H; + transition first Prep accept Next then Run; + transition first Work::Start + do action { + assign this.trace := this.trace * 100 + 23; + } + then Run; + /* not migrated: Transition (_tThrough) — the source 'Through' has no v2 form: (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior */ + transition first Run accept Finish + do action { + assign this.trace := this.trace * 100 + 24; + } + then Work::Leave; + transition first Work::Run::Out + do action { + assign this.trace := this.trace * 100 + 25; + } + then Prep; + } + state Sync { + entry action { + assign this.trace := this.trace * 100 + 31; + } + then regions; + exit action { + assign this.trace := this.trace * 100 + 32; + } + fork Both; + join Gather; + state regions parallel { + state a { + entry; then A0; + state A0; + state A1 { + entry action { + assign this.trace := this.trace * 100 + 33; + } + exit action { + assign this.trace := this.trace * 100 + 34; + } + } + transition first Sync::Both then A1; + transition first A0 accept Bump then A1; + transition first A1 + do action { + assign this.trace := this.trace * 100 + 37; + } + then Sync::Gather; + } + state b { + entry; then B0; + state B0; + state B1 { + entry action { + assign this.trace := this.trace * 100 + 35; + } + exit action { + assign this.trace := this.trace * 100 + 36; + } + } + transition first Sync::Both then B1; + transition first B0 accept Bump then B1; + transition first B1 + do action { + assign this.trace := this.trace * 100 + 38; + } + then Sync::Gather; + } + } + transition first regions then done; + } + transition first Idle accept Go + do action { + assign this.trace := this.trace * 100 + 41; + } + then Work::Start; + transition first Idle accept Enter then Work; + transition first Idle accept Dive + do action { + assign this.trace := this.trace * 100 + 42; + } + then Work::Run::Deep; + transition first Idle accept Resume then Work::H; + /* not migrated: Transition (_tSkip) — the target 'Through' has no v2 form: (_tThrough) leads from the entry point straight to the exit point 'Leave' of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior */ + transition first Work::Leave + do action { + assign this.trace := this.trace * 100 + 43; + } + then Idle; + transition first Work accept Stop + do action { + assign this.trace := this.trace * 100 + 44; + } + then Idle; + transition first Idle accept Pair then Sync; + transition first Idle accept Split then Sync::Both; + transition first Sync::Gather + do action { + assign this.trace := this.trace * 100 + 46; + } + then Idle; + } + exhibit state cycle : Cycle; +} diff --git a/tests/migrate/testdata/xmi/station_points.xmi b/tests/migrate/testdata/xmi/station_points.xmi new file mode 100644 index 0000000000..333eb57e3d --- /dev/null +++ b/tests/migrate/testdata/xmi/station_points.xmi @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JavaScript + trace = trace * 100 + 11; + + + JavaScript + trace = trace * 100 + 12; + + + + + + + + + + JavaScript + trace = trace * 100 + 13; + + + + + JavaScript + trace = trace * 100 + 14; + + + JavaScript + trace = trace * 100 + 15; + + + + + + + + + JavaScript + trace = trace * 100 + 17; + + + + + + JavaScript + trace = trace * 100 + 21; + + + + + + JavaScript + trace = trace * 100 + 22; + + + + + + + + + + + + JavaScript + trace = trace * 100 + 23; + + + + + + + JavaScript + trace = trace * 100 + 24; + + + + + JavaScript + trace = trace * 100 + 25; + + + + + + + + JavaScript + trace = trace * 100 + 31; + + + JavaScript + trace = trace * 100 + 32; + + + + + + + + + JavaScript + trace = trace * 100 + 33; + + + JavaScript + trace = trace * 100 + 34; + + + + + + + + + + JavaScript + trace = trace * 100 + 37; + + + + + + + + + JavaScript + trace = trace * 100 + 35; + + + JavaScript + trace = trace * 100 + 36; + + + + + + + + + + JavaScript + trace = trace * 100 + 38; + + + + + + + + + + JavaScript + trace = trace * 100 + 41; + + + + + + + + + JavaScript + trace = trace * 100 + 42; + + + + + + + + + + + JavaScript + trace = trace * 100 + 43; + + + + + + JavaScript + trace = trace * 100 + 44; + + + + + + + + + + + JavaScript + trace = trace * 100 + 46; + + + + + + + diff --git a/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.golden b/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.golden new file mode 100644 index 0000000000..e9ce555157 --- /dev/null +++ b/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.golden @@ -0,0 +1,41 @@ +(RootNamespace + (Membership visibility="default" + (Definition kind="state" abstract=false variation=false name="Line" + (EntryMember) + (SuccessionEdge source="@entry" target="Idle") + (SubstateMember name="Idle") + (Membership visibility="default" + (Usage kind="state" name="Work" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (EntryMember + (Membership visibility="default" + (Usage kind="action" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="entry"))) + (SuccessionEdge source="@entry" target="Prep") + (PseudostateNode kind="junction" name="start") + (PseudostateNode kind="junction" name="leave") + (PseudostateNode kind="fork" name="spread") + (PseudostateNode kind="join" name="gather") + (SubstateMember name="Prep") + (Membership visibility="default" + (Usage kind="state" name="Run" ref=false direction="none" composite=false derived=false ordered=false nonunique=false parallel=true + (Membership visibility="default" + (Usage kind="state" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (EntryMember) + (SuccessionEdge source="@entry" target="a1") + (SubstateMember name="a1") + (TransitionMember source="a1" target="gather"))) + (Membership visibility="default" + (Usage kind="state" name="b" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (EntryMember) + (SuccessionEdge source="@entry" target="b1") + (SubstateMember name="b1") + (TransitionMember source="b1" target="gather"))))) + (TransitionMember source="start" target="Run") + (TransitionMember source="spread" target="Run::a::a1") + (TransitionMember source="spread" target="Run::b::b1") + (TransitionMember source="Prep" target="leave" trigger="Next"))) + (TransitionMember source="Idle" target="Work::start" trigger="Go") + (TransitionMember source="Idle" target="Work::spread" trigger="Split") + (TransitionMember source="Work::leave" target="Idle" + (Membership visibility="default" + (Usage kind="action" name="effect" ref=false direction="none" composite=false derived=false ordered=false nonunique=false))) + (TransitionMember source="Work::gather" target="Idle")))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.sysml b/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.sysml new file mode 100644 index 0000000000..f85e7e6f60 --- /dev/null +++ b/tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.sysml @@ -0,0 +1,25 @@ +state def Line { + entry; then Idle; + state Idle; + state Work { + entry action { } + then Prep; + junction start; + junction leave; + fork spread; + join gather; + state Prep; + state Run parallel { + state a { entry; then a1; state a1; transition first a1 then gather; } + state b { entry; then b1; state b1; transition first b1 then gather; } + } + transition first start then Run; + transition first spread then Run::a::a1; + transition first spread then Run::b::b1; + transition first Prep accept Next then leave; + } + transition first Idle accept Go then Work::start; + transition first Idle accept Split then Work::spread; + transition first Work::leave do action effect { } then Idle; + transition first Work::gather then Idle; +} From 569381e5b603ca217ac57058ed5e3163b165a805 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:10:26 +0000 Subject: [PATCH 02/12] fix(migrate): read a transition guard serialized as a reference to its owned rule A UML Transition.guard written as a guard="..." reference to an owned rule, as Cameo and the PSSM suite serialize it, is now found and written as the if clause; a LiteralBoolean whose value is omitted is false, the UML default. The join conformance case gains its check expectation. Co-Authored-By: jason.han --- .../transition-guard-by-reference.fixed.md | 1 + docs/reference/sysml-v1-migration.md | 2 +- ...ions_before_owner_exit.check.expected.json | 12 +++++++++ internal/translate/migrate/states.go | 27 ++++++++++++++++--- .../xmi/station_points.golden.report.txt | 8 ++++-- .../testdata/xmi/station_points.golden.sysml | 1 + tests/migrate/testdata/xmi/station_points.xmi | 12 ++++++++- 7 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 changes/unreleased/transition-guard-by-reference.fixed.md create mode 100644 internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json diff --git a/changes/unreleased/transition-guard-by-reference.fixed.md b/changes/unreleased/transition-guard-by-reference.fixed.md new file mode 100644 index 0000000000..ae8c43ae5f --- /dev/null +++ b/changes/unreleased/transition-guard-by-reference.fixed.md @@ -0,0 +1 @@ +- **The SysML v1 migrator reads a transition guard serialized as a reference.** A UML `Transition.guard` some exporters write as a `guard="…"` reference to an owned rule of the transition, rather than as a `guard` child, is now found and written as the `if` clause, reported and kept in a comment when it has no v2 form; before, such a transition was written unguarded and its constraint left out of the report. A `LiteralBoolean` guard whose `value` the file omits is read as `false`, the UML default, where a transition guard is concerned; before, it was taken for `true` and dropped. diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 0929322710..fc0840cb33 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -184,7 +184,7 @@ returned over the service yet. | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | | `entry`, `doActivity`, `exit` behaviors | `entry action { … }` / `do action { … }` / `exit action { … }` inline when the behavior is owned by the state, `entry x;` / `do x : Def;` by reference otherwise | mapped | -| Transition | `transition first s accept Sig if do then t;`; several triggers are several transitions; a completion transition is `transition first s then t;` | mapped (several triggers: approximated) | +| Transition | `transition first s accept Sig if do then t;`; several triggers are several transitions; a completion transition is `transition first s then t;`; the guard is the transition's `guard` child or the owned rule its `guard` reference names, and a `LiteralBoolean` guard whose value the file omits is `false`, the UML default | mapped (several triggers: approximated) | | Transition `effect` with `in` parameters | the accepted signal is named, `accept sig : Sig`, and each parameter typed by the signal (or a general of it), or the sole untyped one, is bound to it: `in p : Sig = sig;`; a parameter of another type takes no value | mapped (an unbound parameter: approximated) | | State `deferrableTrigger` on a SignalEvent | `defer Sig;` in the state's body — the OpenSysML `defer` extension (see [Behavior](../guide/06-behavior.md)), which the runtime executes and the validator reports as non-standard notation | approximated | | Internal transition (`kind = internal`) | a self transition of the state; faithful when the state has no entry, exit or do behavior and no substates (re-entry is not observable), otherwise the exit and entry run where v1 stayed in the state; one without a trigger is a comment, as a self transition would fire again on every re-entry | mapped / approximated / **unmapped** | diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json new file mode 100644 index 0000000000..77cc55ad12 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);\"", + "\"B1(exit);T22(effect);A1(exit);T12(effect);S1(exit);T3(effect);S2(entry);\"" + ] + }, + "agreed": { + "finalState": "S2" + } +} diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 102338f8ca..b1b45bc566 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -244,6 +244,7 @@ func regionWithin(v, owner *sysmlv1.Element) *sysmlv1.Element { // forkBranchWhy says why transition t into tgt cannot be a fork's branch, which // enters a state with no trigger or guard of its own; "" when it can. func (m *migration) forkBranchWhy(t, tgt *sysmlv1.Element) string { + g := m.guardOf(t) switch { case tgt == nil: return "lacks a target" @@ -251,12 +252,30 @@ func (m *migration) forkBranchWhy(t, tgt *sysmlv1.Element) string { return "enters " + describe(tgt) + ", a " + kindOf(tgt) + " rather than a state" case len(t.Owned("trigger")) > 0: return "has a trigger" - case firstOwned(t, "guard") != nil: + case g != nil && !trueLiteral(firstOwned(g, "specification")): return "has a guard" } return "" } +// guardOf returns transition t's guard: its guard child, or the owned rule its guard +// reference names, which is how some exporters serialize the composite property. +func (m *migration) guardOf(t *sysmlv1.Element) *sysmlv1.Element { + if g := firstOwned(t, "guard"); g != nil { + return g + } + if g := m.model.Ref(t, "guard"); g != nil && g.Type == "Constraint" { + return g + } + return nil +} + +// trueLiteral reports whether spec is a LiteralBoolean holding true; a value the +// serialization omits is the UML default, false. +func trueLiteral(spec *sysmlv1.Element) bool { + return spec != nil && spec.Type == "LiteralBoolean" && (spec.Attrs["value"] == "true" || spec.Attrs["value"] == "1") +} + // populatedRegions returns the regions of a machine or state that hold a // vertex; an empty one has nothing to enter, so it is skipped rather than // written as a sub-state no entry starts. @@ -519,7 +538,7 @@ func (s *stateRegion) initial(vertices, transitions []*sysmlv1.Element, entered note = "an initial transition takes no trigger; its triggers are dropped" s.m.add(tr, Unmapped, "", "a trigger of an initial transition, which takes none, is dropped") } - if g := firstOwned(t, "guard"); g != nil { + if g := s.m.guardOf(t); g != nil { text := "the guard " + describe(g) if spec := firstOwned(g, "specification"); spec != nil { text = "[" + describeValue(spec) + "]" @@ -1327,7 +1346,7 @@ func inParameters(b *sysmlv1.Element) []*sysmlv1.Element { // comment when it is not a v2 expression the state machine's owner resolves; // an else guard out of a choice or junction is the unguarded transition. func (s *stateRegion) guard(t, src *sysmlv1.Element) (string, string) { - g := firstOwned(t, "guard") + g := s.m.guardOf(t) if g == nil { return "", "" } @@ -1336,7 +1355,7 @@ func (s *stateRegion) guard(t, src *sysmlv1.Element) (string, string) { s.m.add(g, Unmapped, "", "the guard has no specification") return "", "the guard " + describe(g) + " has no specification and is dropped" } - if spec.Type == "LiteralBoolean" && (spec.Attrs["value"] == "true" || spec.Attrs["value"] == "") { + if trueLiteral(spec) { s.m.add(g, Mapped, "", "a true guard is not written") return "", "" } diff --git a/tests/migrate/testdata/xmi/station_points.golden.report.txt b/tests/migrate/testdata/xmi/station_points.golden.report.txt index a4a2b9a4dd..8803e1282b 100644 --- a/tests/migrate/testdata/xmi/station_points.golden.report.txt +++ b/tests/migrate/testdata/xmi/station_points.golden.report.txt @@ -1,5 +1,5 @@ # SysML v1 to v2 migration report: station_points.xmi -# migrated 122 element(s): 92 mapped, 26 approximated, 4 unmapped (0 skipped as profile, library or notation-only content, 0 as model elements nothing refers to) +# migrated 126 element(s): 96 mapped, 26 approximated, 4 unmapped (0 skipped as profile, library or notation-only content, 0 as model elements nothing refers to) ## unmapped (4) SignalEvent _skipEv (every trigger referring to the event is dropped: it belongs to a behavior that is not written, or to an initial transition) @@ -35,7 +35,7 @@ OpaqueBehavior Station::Cycle::main::Work::steps::Run::pace:::::: _backEff -> Station::Cycle::Work::Run::unnamed2::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) OpaqueBehavior Station::Cycle::main::Work::steps::Run::pace::Fast:: _fastEntry -> Station::Cycle::Work::Run::Fast::unnamed (the JavaScript body is translated to v2; the JavaScript body is written as v2 assignments) -## mapped (92) +## mapped (96) SignalEvent _backEv (written where a trigger refers to it, as accept Back) SignalEvent _nextEv (written where a trigger refers to it, as accept Next) SignalEvent _finishEv (written where a trigger refers to it, as accept Finish) @@ -63,6 +63,7 @@ Region Station::Cycle::main _main (the one region is written as the body of its Pseudostate Station::Cycle::main:: _init0 (written as the entry of the region) Transition Station::Cycle::main:: _t0 Transition Station::Cycle::main:: _tGo (the target 'Start' lies in another region and is named by its path Work::Start) +Transition Station::Cycle::main:: _tNever Transition Station::Cycle::main:: _tEnter (written to Work: no transition leaves the entry point 'Plain', so entering through it enters the state by its default entry) Transition Station::Cycle::main:: _tDive (the target 'Deep' lies in another region and is named by its path Work::Run::Deep) Transition Station::Cycle::main:: _tResume (the target 'H' lies in another region and is named by its path Work::H) @@ -72,12 +73,14 @@ Transition Station::Cycle::main:: _tPair Transition Station::Cycle::main:: _tSplit (the target 'Both' lies in another region and is named by its path Sync::Both) Transition Station::Cycle::main:: _tGather (the source 'Gather' lies in another region and is named by its path Sync::Gather) Trigger Station::Cycle::main:::: _trGo +Trigger Station::Cycle::main:::: _trNever Trigger Station::Cycle::main:::: _trEnter Trigger Station::Cycle::main:::: _trDive Trigger Station::Cycle::main:::: _trResume Trigger Station::Cycle::main:::: _trStop Trigger Station::Cycle::main:::: _trPair Trigger Station::Cycle::main:::: _trSplit +Constraint Station::Cycle::main::::never _neverGuard State Station::Cycle::main::Idle _idle -> Idle State Station::Cycle::main::Sync _sync -> Sync Pseudostate Station::Cycle::main::Sync::Both _both -> Both (written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches) @@ -88,6 +91,7 @@ Transition Station::Cycle::main::Sync::a:: _tAi Transition Station::Cycle::main::Sync::a:: _tBothA (the source 'Both' lies in another region and is named by its path Sync::Both) Transition Station::Cycle::main::Sync::a:: _tA Transition Station::Cycle::main::Sync::a:: _tAg (the target 'Gather' lies in another region and is named by its path Sync::Gather) +Constraint Station::Cycle::main::Sync::a:::: _bothAGuard (a true guard is not written) Trigger Station::Cycle::main::Sync::a:::: _trA State Station::Cycle::main::Sync::a::A0 _a0 -> A0 State Station::Cycle::main::Sync::a::A1 _a1 -> A1 diff --git a/tests/migrate/testdata/xmi/station_points.golden.sysml b/tests/migrate/testdata/xmi/station_points.golden.sysml index 9ac55f2c3f..2352bb7782 100644 --- a/tests/migrate/testdata/xmi/station_points.golden.sysml +++ b/tests/migrate/testdata/xmi/station_points.golden.sysml @@ -134,6 +134,7 @@ part def Station { assign this.trace := this.trace * 100 + 41; } then Work::Start; + transition first Idle accept Go if false then Work; transition first Idle accept Enter then Work; transition first Idle accept Dive do action { diff --git a/tests/migrate/testdata/xmi/station_points.xmi b/tests/migrate/testdata/xmi/station_points.xmi index 333eb57e3d..86c2fab259 100644 --- a/tests/migrate/testdata/xmi/station_points.xmi +++ b/tests/migrate/testdata/xmi/station_points.xmi @@ -149,7 +149,11 @@ - + + + + + @@ -195,6 +199,12 @@ trace = trace * 100 + 41; + + + + + + From e7626c7a11792677e6462df9b876f5eea56d6349 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:16:43 +0000 Subject: [PATCH 03/12] fix(migrate): name the shape when an exit point several regions reach is also reached from outside Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 2 +- internal/translate/migrate/states.go | 5 ++ tests/migrate/states_test.go | 101 +++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index fc0840cb33..c1a45dd72f 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -179,7 +179,7 @@ returned over the service yet. | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | | Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | | Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired | mapped | -| Entry point leading straight to an exit point of the same state; an entry point whose outgoing transitions leave the state, or several of which start the same region; an exit point with several incoming transitions from the same region (or from outside its regions), or with several outgoing transitions; a connection point route into a history pseudostate | refused with the shape named | unmapped | +| Entry point leading straight to an exit point of the same state; an entry point whose outgoing transitions leave the state, or several of which start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index b1b45bc566..03e4724ae1 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -208,6 +208,11 @@ func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { return pointForm{kw: "junction", note: "written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it"} } if len(regions) < len(in) { + for _, t := range in { + if regionWithin(m.model.Ref(t, "source"), owner) == nil { + return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " + describe(t) + " comes from outside the state"} + } + } return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but two of its incoming transitions leave the same region"} } for _, t := range in { diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 9b52e414f7..8e07b32a97 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -634,3 +634,104 @@ func TestEmptyTransitionEffectKeepsItsBraces(t *testing.T) { t.Errorf("the transition with the empty effect did not fire:\n%s", out) } } + +// joinShapesMachine has three orthogonal states whose exit point several regions reach, each +// also reached in a way a join cannot take: twice from one region, from outside the state, +// and from a junction. +const joinShapesMachine = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const joinShapesApplications = ` + ` + +// An exit point several regions reach is a join; one also reached twice from one region, from +// outside its state, or from a pseudostate is refused with that shape named, its transitions with it. +func TestExitPointJoinShapesAreRefusedPrecisely(t *testing.T) { + r := migrateDocument(t, joinShapesMachine, joinShapesApplications) + if strings.Contains(string(r.Notation), "join out;") { + t.Errorf("a refused exit point was written as a join:\n%s", r.Notation) + } + wantNote(t, r, "_jxTwice", migrate.Unmapped, "as through a join, but two of its incoming transitions leave the same region") + wantNote(t, r, "_jxOuter", migrate.Unmapped, "as through a join, but (_jtIn) comes from outside the state") + wantNote(t, r, "_jxPseudo", migrate.Unmapped, "as through a join, but (_jpaT2) leaves 'j', a Pseudostate rather than a state") + for _, id := range []string{"_jtaT1", "_jtaT2", "_jtbT1", "_jtTwice", "_joaT1", "_jobT1", "_jtIn", "_jtOuter", "_jpaT2", "_jpbT1", "_jtPseudo"} { + wantNote(t, r, id, migrate.Unmapped, "has no v2 form") + } +} From 93bf17d9cf6adc7950920f89112e8451889c420d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:28:39 +0000 Subject: [PATCH 04/12] fix(migrate): refuse entry-point routes a junction cannot follow; index nested machines once An entry point whose single transition leaves its state, leads back to the state itself, runs on into a history pseudostate or has no target was still written as a junction, which the runtime either follows to the wrong place or reports as a dead end; each such route is now refused with its shape named, the transitions through it with it. indexTransitions walked into state machines nested in the one being indexed, so a nested machine named after its owner was indexed twice and a fork entry point saw each branch twice. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 9 +- internal/translate/migrate/states.go | 31 ++++- tests/migrate/states_test.go | 165 +++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index c1a45dd72f..e43a803b60 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -179,7 +179,7 @@ returned over the service yet. | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | | Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | | Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired | mapped | -| Entry point leading straight to an exit point of the same state; an entry point whose outgoing transitions leave the state, or several of which start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | +| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or several of whose outgoing transitions start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | @@ -468,9 +468,10 @@ orthogonal state is a `fork`, an exit point its regions reach from each side a ` point no transition leaves is the state's default entry, and the transition is written to the state. An entry point that leads straight to an exit point of the same state, so the state is crossed without settling in it, is refused: the runtime would run neither its entry nor its exit -behavior; so is a route from a connection point on into a history pseudostate, and any point -whose transitions do not form one of the shapes above. An internal transition is a self -transition, faithful when re-entering the state is not observable (no entry, exit, do or +behavior; so is an entry point whose transition leads back to the state itself (v1 enters it by +its default entry where the runtime would leave and re-enter it), out of the state, on into a +history pseudostate or to no target, and any point whose transitions do not form one of the +shapes above. An internal transition is a self transition, faithful when re-entering the state is not observable (no entry, exit, do or substates) and reported otherwise; one written with no target stays in its source, one that targets another vertex or leaves a pseudostate is refused, and one without a trigger is a comment, as a self transition would fire again on every re-entry. A transition into an exit point of a diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 03e4724ae1..923292442b 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -51,12 +51,15 @@ func (m *migration) nameMachine(sm *sysmlv1.Element) map[string]bool { return used } -// indexTransitions lists the transitions into and out of every vertex of a -// machine, so a connection point's shape can be read before it is written. +// indexTransitions lists the transitions into and out of every vertex of a machine, so a +// connection point's shape can be read before it is written; a machine nested in it indexes its own. func (m *migration) indexTransitions(sm *sysmlv1.Element) { var walk func(e *sysmlv1.Element) walk = func(e *sysmlv1.Element) { for _, c := range e.Children { + if c.Type == "StateMachine" { + continue + } if c.Role == "transition" { if src := m.model.Ref(c, "source"); src != nil { m.outgoing[src] = append(m.outgoing[src], c) @@ -181,9 +184,8 @@ func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { return pointForm{defaultEntry: true, note: "no transition leaves the entry point, so entering through it enters " + describe(owner) + " by its default entry; a transition to it is written to the state"} } for _, t := range out { - tgt := m.model.Ref(t, "target") - if tgt != nil && pseudoKind(tgt) == "exitPoint" && pointOwner(tgt) == owner { - return pointForm{why: describe(t) + " leads from the entry point straight to the exit point " + describe(tgt) + " of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior"} + if why := m.entryBranchWhy(t, owner); why != "" { + return pointForm{why: describe(t) + " leads from the entry point " + why} } } regions := m.regionsCrossed(out, owner, "target") @@ -201,6 +203,25 @@ func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { return pointForm{kw: "fork", note: "written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches"} } +// entryBranchWhy says why transition t, leaving an entry point of owner, keeps the point from +// being written: its target is missing, owner itself or outside it, a history, or an exit point of owner. +func (m *migration) entryBranchWhy(t, owner *sysmlv1.Element) string { + tgt := m.model.Ref(t, "target") + switch { + case tgt == nil: + return "to no target" + case tgt == owner: + return "back to the state itself, which v1 enters by its default entry while the runtime would leave and re-enter it" + case pseudoKind(tgt) == "exitPoint" && pointOwner(tgt) == owner: + return "straight to the exit point " + describe(tgt) + " of the same state, crossing it without settling in it; the runtime would then run neither its entry nor its exit behavior" + case pseudoKind(tgt) == "shallowHistory" || pseudoKind(tgt) == "deepHistory": + return "on into the history pseudostate " + describe(tgt) + ", which the runtime does not follow from a junction" + case regionWithin(tgt, owner) == nil: + return "out of the state, to " + describe(tgt) + } + return "" +} + func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { in := m.incoming[v] regions := m.regionsCrossed(in, owner, "source") diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 8e07b32a97..d93617cf1b 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -735,3 +735,168 @@ func TestExitPointJoinShapesAreRefusedPrecisely(t *testing.T) { wantNote(t, r, id, migrate.Unmapped, "has no v2 form") } } + +// entryShapesMachine has four composite states whose entry point leaves by a route a junction +// cannot take: out of the state, on into the state's history, to no target, and back to the state. +const entryShapesMachine = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const entryShapesApplications = ` + ` + +// An entry point whose route leaves the state, runs on into a history, reaches no target or leads +// back to the state is refused with that route named, the transitions through it with it, and no +// junction is written. +func TestEntryPointRoutesAreRefusedPrecisely(t *testing.T) { + r := migrateDocument(t, entryShapesMachine, entryShapesApplications) + if strings.Contains(string(r.Notation), "junction in;") { + t.Errorf("a refused entry point was written as a junction:\n%s", r.Notation) + } + wantNote(t, r, "_eaIn", migrate.Unmapped, "(_etOut) leads from the entry point out of the state, to 'Idle'") + wantNote(t, r, "_ebIn", migrate.Unmapped, "(_ebrT1) leads from the entry point on into the history pseudostate 'H', which the runtime does not follow from a junction") + wantNote(t, r, "_ecIn", migrate.Unmapped, "(_ecrT1) leads from the entry point to no target") + wantNote(t, r, "_edIn", migrate.Unmapped, "(_edrT1) leads from the entry point back to the state itself, which v1 enters by its default entry while the runtime would leave and re-enter it") + for _, id := range []string{"_etAway", "_etOut", "_etBack", "_etBare", "_etSelf", "_edrT1"} { + wantNote(t, r, id, migrate.Unmapped, "has no v2 form") + } + wantNote(t, r, "_ebrT1", migrate.Unmapped, "does not follow a transition from a entryPoint pseudostate on into the history pseudostate") + wantNote(t, r, "_ecrT1", migrate.Unmapped, "lacks an end") +} + +// nestedMachines has a machine Front whose submachine state enters Outer through a connection +// point reference, so Outer is named before it is written, and a machine Inner nested in Outer +// whose composite state Par has an entry point forking into its two regions. +const nestedMachines = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const nestedMachinesApplications = ` + ` + +// A machine nested in another is indexed once however early the outer one is named, so the +// entry point of its composite state still forks into two regions rather than four transitions. +func TestNestedMachineTransitionsAreIndexedOnce(t *testing.T) { + r := migrateDocument(t, nestedMachines, nestedMachinesApplications) + for _, line := range []string{ + "fork split;", + "transition first Par::split then A2;", + "transition first Par::split then B2;", + "transition first Idle accept Go then Par::split;", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + wantNote(t, r, "_niIn", migrate.Mapped, "written as a fork of its state") +} From 2b44d181e5415cc58193bbe97534c626f4d2a8a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:56:47 +0000 Subject: [PATCH 05/12] fix(migrate): refuse triggered entry-point routes and name region-listed connection points through their state An entry point whose outgoing transition has a trigger is refused: a junction's transition is followed at once, so the runtime would not wait for the event. A guard on that transition is kept, with a test proving the runtime evaluates it when selecting the transition, before the state's entry behavior, as UML evaluates a compound transition's guards before it fires. A connection point a tool lists among a region's vertices rather than as the state's connectionPoint is written in the state's body and named through the state (Sync::Both), not through the region's path. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 13 +- internal/translate/migrate/names.go | 3 +- internal/translate/migrate/states.go | 27 +++-- tests/migrate/states_test.go | 170 ++++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 20 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index e43a803b60..a3927245c4 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -179,7 +179,7 @@ returned over the service yet. | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | | Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | | Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired | mapped | -| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or several of whose outgoing transitions start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | +| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or whose outgoing transition has a trigger, or several of whose outgoing transitions start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | @@ -470,8 +470,15 @@ state. An entry point that leads straight to an exit point of the same state, so crossed without settling in it, is refused: the runtime would run neither its entry nor its exit behavior; so is an entry point whose transition leads back to the state itself (v1 enters it by its default entry where the runtime would leave and re-enter it), out of the state, on into a -history pseudostate or to no target, and any point whose transitions do not form one of the -shapes above. An internal transition is a self transition, faithful when re-entering the state is not observable (no entry, exit, do or +history pseudostate or to no target, one whose transition has a trigger (a junction's transition +is followed at once, not on an event), and any point whose transitions do not form one of the +shapes above. A guard on that transition is kept: UML evaluates a junction's guards with the +rest of the compound transition's before it fires, not after entering the state, and the +runtime evaluates the junction's guard when it selects the transition; where UML leaves the +compound transition disabled by a false guard, the runtime reports it, as at any junction. +A connection point a tool lists among a region's vertices rather than as the state's +`connectionPoint` is still the state's, and is named through the state, not the region. An +internal transition is a self transition, faithful when re-entering the state is not observable (no entry, exit, do or substates) and reported otherwise; one written with no target stays in its source, one that targets another vertex or leaves a pseudostate is refused, and one without a trigger is a comment, as a self transition would fire again on every re-entry. A transition into an exit point of a diff --git a/internal/translate/migrate/names.go b/internal/translate/migrate/names.go index f0a9162ff9..aee23cb2b2 100644 --- a/internal/translate/migrate/names.go +++ b/internal/translate/migrate/names.go @@ -83,9 +83,10 @@ func lowerFirst(s string) string { // segments returns the v2 qualified-name segments of an element: the names // from the top-level declaration down, the root Model not being written. A // lone region is its owner's body; one of several is a sub-state of a parallel state. +// A connection point is a member of its owner, whichever region a tool listed it in. func (m *migration) segments(e *sysmlv1.Element) []string { var segs []string - for cur := e; cur != nil; cur = cur.Parent { + for cur := e; cur != nil; cur = memberOwner(cur) { if cur.Parent == nil && cur.Type == "Model" { break } diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 923292442b..8ba3960d72 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -155,6 +155,15 @@ func pointOwner(v *sysmlv1.Element) *sysmlv1.Element { return owner } +// memberOwner is the element whose body vertex v is written in: the owner of a +// connection point, however a tool listed it, else v's parent. +func memberOwner(v *sysmlv1.Element) *sysmlv1.Element { + if owner := pointOwner(v); owner != nil { + return owner + } + return v.Parent +} + // pointForm says how a composite state's entry or exit point is written: as // the pseudostate kw of the state's body, or not at all. type pointForm struct { @@ -203,11 +212,13 @@ func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { return pointForm{kw: "fork", note: "written as a fork of its state, whose branches start its regions; a transition entering through it runs the state's entry behavior, then the branches"} } -// entryBranchWhy says why transition t, leaving an entry point of owner, keeps the point from -// being written: its target is missing, owner itself or outside it, a history, or an exit point of owner. +// entryBranchWhy says why transition t, leaving an entry point of owner, keeps the point from being +// written: it has a trigger, or its target is missing, owner itself or outside it, a history, or an exit point of owner. func (m *migration) entryBranchWhy(t, owner *sysmlv1.Element) string { tgt := m.model.Ref(t, "target") switch { + case len(t.Owned("trigger")) > 0: + return "with a trigger, which no transition out of a pseudostate takes; the runtime would follow it without waiting for the event" case tgt == nil: return "to no target" case tgt == owner: @@ -267,17 +278,13 @@ func regionWithin(v, owner *sysmlv1.Element) *sysmlv1.Element { return nil } -// forkBranchWhy says why transition t into tgt cannot be a fork's branch, which -// enters a state with no trigger or guard of its own; "" when it can. +// forkBranchWhy says why transition t into tgt, a route entryBranchWhy passed, cannot be a +// fork's branch, which enters a state with no guard of its own; "" when it can. func (m *migration) forkBranchWhy(t, tgt *sysmlv1.Element) string { g := m.guardOf(t) switch { - case tgt == nil: - return "lacks a target" case tgt.Type != "State": return "enters " + describe(tgt) + ", a " + kindOf(tgt) + " rather than a state" - case len(t.Owned("trigger")) > 0: - return "has a trigger" case g != nil && !trueLiteral(firstOwned(g, "specification")): return "has a guard" } @@ -915,7 +922,7 @@ func (s *stateRegion) path(v *sysmlv1.Element) (string, bool) { if !ok || machineOf(v) != s.machine { return "", false } - if v.Parent == s.r || v.Parent == s.machine { + if owner := memberOwner(v); owner == s.r || owner == s.machine { return writeName(name), true } segs := s.m.segments(v) @@ -929,7 +936,7 @@ func (s *stateRegion) endpoint(t, v *sysmlv1.Element, role string) (string, bool if !ok { return "", false } - if v.Parent != s.r && v.Parent != s.machine { + if owner := memberOwner(v); owner != s.r && owner != s.machine { s.m.add(t, Mapped, "", "the "+role+" "+describe(v)+" lies in another region and is named by its path "+p) } return p, true diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index d93617cf1b..e4d3a2b3c3 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -736,8 +736,9 @@ func TestExitPointJoinShapesAreRefusedPrecisely(t *testing.T) { } } -// entryShapesMachine has four composite states whose entry point leaves by a route a junction -// cannot take: out of the state, on into the state's history, to no target, and back to the state. +// entryShapesMachine has five composite states whose entry point leaves by a route a junction +// cannot take: out of the state, on into the state's history, to no target, back to the state, +// and on a trigger. const entryShapesMachine = ` @@ -782,7 +783,22 @@ const entryShapesMachine = ` + + + + + + + + + + + + + + + @@ -803,9 +819,9 @@ const entryShapesMachine = ` const entryShapesApplications = ` ` -// An entry point whose route leaves the state, runs on into a history, reaches no target or leads -// back to the state is refused with that route named, the transitions through it with it, and no -// junction is written. +// An entry point whose route leaves the state, runs on into a history, reaches no target, leads +// back to the state or waits on a trigger is refused with that route named, the transitions +// through it with it, and no junction is written. func TestEntryPointRoutesAreRefusedPrecisely(t *testing.T) { r := migrateDocument(t, entryShapesMachine, entryShapesApplications) if strings.Contains(string(r.Notation), "junction in;") { @@ -815,13 +831,87 @@ func TestEntryPointRoutesAreRefusedPrecisely(t *testing.T) { wantNote(t, r, "_ebIn", migrate.Unmapped, "(_ebrT1) leads from the entry point on into the history pseudostate 'H', which the runtime does not follow from a junction") wantNote(t, r, "_ecIn", migrate.Unmapped, "(_ecrT1) leads from the entry point to no target") wantNote(t, r, "_edIn", migrate.Unmapped, "(_edrT1) leads from the entry point back to the state itself, which v1 enters by its default entry while the runtime would leave and re-enter it") - for _, id := range []string{"_etAway", "_etOut", "_etBack", "_etBare", "_etSelf", "_edrT1"} { + wantNote(t, r, "_eeIn", migrate.Unmapped, "(_eerT1) leads from the entry point with a trigger, which no transition out of a pseudostate takes; the runtime would follow it without waiting for the event") + for _, id := range []string{"_etAway", "_etOut", "_etBack", "_etBare", "_etSelf", "_edrT1", "_etWait", "_eerT1"} { wantNote(t, r, id, migrate.Unmapped, "has no v2 form") } wantNote(t, r, "_ebrT1", migrate.Unmapped, "does not follow a transition from a entryPoint pseudostate on into the history pseudostate") wantNote(t, r, "_ecrT1", migrate.Unmapped, "lacks an end") } +// guardedEntryMachine has a composite state whose entry point leaves by a guarded transition, +// and whose entry behavior falsifies that guard. +const guardedEntryMachine = ` + + + + + + + + + + + + + + JavaScript + armed = false; + + + + + + + + + + + JavaScript + armed + + + + + + + + + + + + ` + +const guardedEntryApplications = ` + ` + +// A guard on the transition leaving an entry point is kept: UML evaluates it with the compound +// transition's other guards before the transition fires, as the runtime does at a junction, so +// the owning state's entry behavior falsifying it does not turn the route. +func TestGuardedEntryPointRouteIsKept(t *testing.T) { + r := migrateDocument(t, guardedEntryMachine, guardedEntryApplications) + for _, line := range []string{"junction arm;", "transition first Work::arm if this.armed then W2;", "transition first Idle accept Go then Work::arm;"} { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + wantNote(t, r, "_gIn", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_gGuard", migrate.Mapped, "") + s := session(t, r) + meta(t, s, "%instantiate Rig") + meta(t, s, "%state Rig::Main #1") + if out := meta(t, s, "%send Go"); !strings.Contains(out, "transition Idle -> arm fires on it") { + t.Errorf("%%send Go: %s", out) + } + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: W2") { + t.Errorf("the guarded entry route was not taken on the guard's value before Work's entry:\n%s", out) + } + if out := meta(t, s, "%eval in #1 : armed"); !strings.Contains(out, "= false") { + t.Errorf("Work's entry behavior did not run: %s", out) + } +} + // nestedMachines has a machine Front whose submachine state enters Outer through a connection // point reference, so Outer is named before it is written, and a machine Inner nested in Outer // whose composite state Par has an entry point forking into its two regions. @@ -900,3 +990,71 @@ func TestNestedMachineTransitionsAreIndexedOnce(t *testing.T) { } wantNote(t, r, "_niIn", migrate.Mapped, "written as a fork of its state") } + +// regionListedPoints has an orthogonal state Sync whose entry point Both and exit point Gather a +// tool listed among the vertices of its regions rather than as its connection points. +const regionListedPoints = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const regionListedPointsApplications = ` + ` + +// A connection point a tool lists in a region of an orthogonal state is written in the state's +// body, and every transition through it names it there, not in the region it was listed in. +func TestRegionListedPointsArePathedFromTheirState(t *testing.T) { + r := migrateDocument(t, regionListedPoints, regionListedPointsApplications) + for _, line := range []string{ + "fork Both;", + "join Gather;", + "transition first Idle accept Go then Sync::Both;", + "transition first Sync::Both then A2;", + "transition first Sync::Both then B2;", + "transition first A2 then Sync::Gather;", + "transition first B2 then Sync::Gather;", + "transition first Sync::Gather then Idle;", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + if strings.Contains(string(r.Notation), "regions::") { + t.Errorf("a connection point is named through the region it was listed in:\n%s", r.Notation) + } + wantNote(t, r, "_rBoth", migrate.Mapped, "written as a fork of its state") + wantNote(t, r, "_rGather", migrate.Mapped, "written as a join of its state") + session(t, r) +} From cef58b4dfaf1a9f9b3f91893844ebd250c684ba7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:29:48 +0000 Subject: [PATCH 06/12] fix(migrate): skip point-only regions and refuse exit points reached from outside their state A region listing nothing but its state's connection points, which a tool may place there, no longer counts as a populated region: it is skipped in the report and its transitions are written in the state's body, so an orthogonal state with one real region is not written parallel with an empty branch. An exit point any transition reaches from outside its state is refused with that transition named, however few reach it, since a junction would enter and leave the state; a local transition of the state into its own exit point stays a junction under the local-transition rule. An entry point no transition leaves notes the owner's regions that have no initial pseudostate. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 4 +- internal/translate/migrate/states.go | 110 ++++++++++--- tests/migrate/states_test.go | 223 ++++++++++++++++++++++++++- 3 files changed, 313 insertions(+), 24 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index a3927245c4..644a9e6f88 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -178,8 +178,8 @@ returned over the service yet. | Pseudostate shallowHistory, deepHistory | `history x;` / `deep history x;` in the composite state; a transition targeting it re-enters the substate (the innermost substates) active when the state was last left, the history's own outgoing transition being its default | mapped | | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | | Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | -| Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired | mapped | -| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or whose outgoing transition has a trigger, or several of whose outgoing transitions start the same region; an exit point several regions reach that is also reached twice from one region, from outside the state, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | +| Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired. A connection point a tool lists among a region's vertices belongs to the state all the same; a region listing nothing else is skipped, not written as a region of a `parallel` state | mapped | +| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or whose outgoing transition has a trigger, or several of whose outgoing transitions start the same region; an exit point reached from outside its state, or one several regions reach that is also reached twice from one region, from the state's own local transition, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 8ba3960d72..fb411036e2 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -106,6 +106,9 @@ func (m *migration) nameRegion(r, owner *sysmlv1.Element, used map[string]bool) inner := inheritedStateNamesSet() m.namePoints(v, inner) m.nameRegions(m.populatedRegions(v), v, inner) + for _, pr := range pointRegions(v) { + m.regionUsed[pr] = inner + } continue } if pointOwner(v) != nil && pointOwner(v).Type == "State" { @@ -116,6 +119,32 @@ func (m *migration) nameRegion(r, owner *sysmlv1.Element, used map[string]bool) } } +// pointRegions lists the regions of state v holding nothing but connection points +// of v, which a tool listed there: their transitions are written in v's body. +func pointRegions(v *sysmlv1.Element) []*sysmlv1.Element { + var out []*sysmlv1.Element + for _, r := range v.Owned("region") { + if len(r.Owned("subvertex")) > 0 && pointsOnly(r, v) { + out = append(out, r) + } + } + return out +} + +// pointsOnly reports whether every vertex region r lists is a connection point of +// the state owner, written in the state's body rather than the region's. +func pointsOnly(r, owner *sysmlv1.Element) bool { + if owner.Type != "State" { + return false + } + for _, v := range r.Owned("subvertex") { + if pointOwner(v) != owner { + return false + } + } + return true +} + // namePoints settles how each connection point of composite state v is written // and names those written as members of its body, whose names used lists. func (m *migration) namePoints(v *sysmlv1.Element, used map[string]bool) { @@ -190,7 +219,11 @@ func (m *migration) statePointForm(v, owner *sysmlv1.Element) pointForm { func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { out := m.outgoing[v] if len(out) == 0 { - return pointForm{defaultEntry: true, note: "no transition leaves the entry point, so entering through it enters " + describe(owner) + " by its default entry; a transition to it is written to the state"} + note := "no transition leaves the entry point, so entering through it enters " + describe(owner) + " by its default entry; a transition to it is written to the state" + if without := regionsWithoutInitial(m.populatedRegions(owner)); len(without) > 0 { + note += "; the " + pluralRegion(len(without)) + " " + strings.Join(without, ", ") + " have no initial pseudostate, so the state is entered with them inactive, as v1 enters it" + } + return pointForm{defaultEntry: true, note: note} } for _, t := range out { if why := m.entryBranchWhy(t, owner); why != "" { @@ -234,22 +267,37 @@ func (m *migration) entryBranchWhy(t, owner *sysmlv1.Element) string { } func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { - in := m.incoming[v] + // in holds the transitions from within the state that shape the point; one from the + // state itself is its local transition, one from its entry point is refused with that. + var in, local []*sysmlv1.Element + for _, t := range m.incoming[v] { + src := m.model.Ref(t, "source") + switch { + case src == nil: + return pointForm{why: describe(t) + " leads to the exit point from no source"} + case src == owner: + local = append(local, t) + case pseudoKind(src) == "entryPoint" && pointOwner(src) == owner: + case regionWithin(src, owner) == nil: + return pointForm{why: describe(t) + " leads to the exit point from outside the state, from " + describe(src) + "; v1 never enters the state, while the runtime would enter and leave it, running its entry and exit behaviors"} + default: + in = append(in, t) + } + } regions := m.regionsCrossed(in, owner, "source") if len(in) < 2 || len(regions) < 2 { return pointForm{kw: "junction", note: "written as a junction of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it"} } + joinBut := "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " if len(regions) < len(in) { - for _, t := range in { - if regionWithin(m.model.Ref(t, "source"), owner) == nil { - return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " + describe(t) + " comes from outside the state"} - } - } - return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but two of its incoming transitions leave the same region"} + return pointForm{why: joinBut + "two of its incoming transitions leave the same region"} + } + if len(local) > 0 { + return pointForm{why: joinBut + describe(local[0]) + " leaves the state itself, which a join does not wait for"} } for _, t := range in { - if src := m.model.Ref(t, "source"); src != nil && src.Type != "State" { - return pointForm{why: "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " + describe(t) + " leaves " + describe(src) + ", a " + kindOf(src) + " rather than a state"} + if src := m.model.Ref(t, "source"); src.Type != "State" { + return pointForm{why: joinBut + describe(t) + " leaves " + describe(src) + ", a " + kindOf(src) + " rather than a state"} } } return pointForm{kw: "join", note: "written as a join of its state, which its regions leave through together; the transitions into the join run, then the state's exit behavior, then the transition leaving it"} @@ -309,17 +357,20 @@ func trueLiteral(spec *sysmlv1.Element) bool { return spec != nil && spec.Type == "LiteralBoolean" && (spec.Attrs["value"] == "true" || spec.Attrs["value"] == "1") } -// populatedRegions returns the regions of a machine or state that hold a -// vertex; an empty one has nothing to enter, so it is skipped rather than -// written as a sub-state no entry starts. +// populatedRegions returns the regions of a machine or state that hold a vertex +// of their own; an empty one, or one listing only its state's connection points, +// has nothing to enter, so it is skipped rather than written as a sub-state no entry starts. func (m *migration) populatedRegions(owner *sysmlv1.Element) []*sysmlv1.Element { var out []*sysmlv1.Element for _, r := range owner.Owned("region") { - if len(r.Owned("subvertex")) == 0 { + switch { + case len(r.Owned("subvertex")) == 0: m.add(r, Skipped, "", unreferencedNote+": the region holds no vertex, so nothing enters it and no state is written for it") - continue + case pointsOnly(r, owner): + m.add(r, Skipped, "", unreferencedNote+": the region lists only connection points of its state, which are written in the state's body, so nothing enters it and no state is written for it") + default: + out = append(out, r) } - out = append(out, r) } return out } @@ -712,7 +763,8 @@ func (s *stateRegion) state(v *sysmlv1.Element) { entry, do, exit := s.m.stateBehavior(v, "entry"), s.m.stateBehavior(v, "doActivity"), s.m.stateBehavior(v, "exit") inv := firstOwned(v, "stateInvariant") points := s.m.connectionPoints(v) - if entry == nil && do == nil && exit == nil && inv == nil && len(regions) == 0 && len(defers) == 0 && s.m.writtenPoints(v) == 0 { + pointRegs := pointRegions(v) + if entry == nil && do == nil && exit == nil && inv == nil && len(regions) == 0 && len(defers) == 0 && s.m.writtenPoints(v) == 0 && !hasTransitions(pointRegs) { s.m.w.line(head + ";") s.m.statePoints(v) return @@ -737,12 +789,32 @@ func (s *stateRegion) state(v *sysmlv1.Element) { } if len(regions) == 0 { between() - return + } else { + s.m.regions(v, regions, entered, between) + } + for _, pr := range pointRegs { + // Named from the state's one region when it has one, so its vertices need no path. + host := pr + if len(regions) == 1 { + host = regions[0] + } + for _, t := range pr.Owned("transition") { + s.m.region(host).transition(t) + } } - s.m.regions(v, regions, entered, between) }) } +// hasTransitions reports whether any of the regions owns a transition. +func hasTransitions(regions []*sysmlv1.Element) bool { + for _, r := range regions { + if len(r.Owned("transition")) > 0 { + return true + } + } + return false +} + // invariant keeps a state invariant, which v2 has no form for, as a comment. func (m *migration) invariant(inv *sysmlv1.Element) { note := "a state invariant has no v2 form" diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index e4d3a2b3c3..4b540b6b20 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -721,15 +721,16 @@ const joinShapesMachine = ` const joinShapesApplications = ` ` -// An exit point several regions reach is a join; one also reached twice from one region, from -// outside its state, or from a pseudostate is refused with that shape named, its transitions with it. +// An exit point several regions reach is a join; one also reached twice from one region or from +// a pseudostate is refused with that shape named, one reached from outside its state with that +// transition named, and their transitions with them. func TestExitPointJoinShapesAreRefusedPrecisely(t *testing.T) { r := migrateDocument(t, joinShapesMachine, joinShapesApplications) if strings.Contains(string(r.Notation), "join out;") { t.Errorf("a refused exit point was written as a join:\n%s", r.Notation) } wantNote(t, r, "_jxTwice", migrate.Unmapped, "as through a join, but two of its incoming transitions leave the same region") - wantNote(t, r, "_jxOuter", migrate.Unmapped, "as through a join, but (_jtIn) comes from outside the state") + wantNote(t, r, "_jxOuter", migrate.Unmapped, "(_jtIn) leads to the exit point from outside the state, from 'Idle'") wantNote(t, r, "_jxPseudo", migrate.Unmapped, "as through a join, but (_jpaT2) leaves 'j', a Pseudostate rather than a state") for _, id := range []string{"_jtaT1", "_jtaT2", "_jtbT1", "_jtTwice", "_joaT1", "_jobT1", "_jtIn", "_jtOuter", "_jpaT2", "_jpbT1", "_jtPseudo"} { wantNote(t, r, id, migrate.Unmapped, "has no v2 form") @@ -1058,3 +1059,219 @@ func TestRegionListedPointsArePathedFromTheirState(t *testing.T) { wantNote(t, r, "_rGather", migrate.Mapped, "written as a join of its state") session(t, r) } + +// pointOnlyRegion has a state Sync with one region of its own, a, and a second, b, in which a +// tool listed nothing but Sync's entry point Both and exit point Gather and the transition +// leaving Both. +const pointOnlyRegion = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const pointOnlyRegionApplications = ` + ` + +// A region listing only its state's connection points holds nothing to enter: it is skipped, so the +// state has one region and its points are junctions, not a fork and a join into a parallel state +// with an empty branch; the transitions the region holds are written in the state's body. +func TestPointOnlyRegionIsNotAParallelBranch(t *testing.T) { + r := migrateDocument(t, pointOnlyRegion, pointOnlyRegionApplications) + for _, line := range []string{ + "junction Both;", + "junction Gather;", + "transition first Idle accept Go then Sync::Both;", + "transition first Sync::Both then A2;", + "transition first A2 accept Stop then Sync::Gather;", + "transition first Sync::Gather then Idle;", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + for _, bad := range []string{"parallel", "regions::", "fork ", "join "} { + if strings.Contains(string(r.Notation), bad) { + t.Errorf("the point-only region was written as a parallel branch (%q):\n%s", bad, r.Notation) + } + } + wantNote(t, r, "_pb", migrate.Skipped, "the region lists only connection points of its state, which are written in the state's body") + wantNote(t, r, "_pBoth", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_pGather", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_pbT1", migrate.Mapped, "") + s := session(t, r) + meta(t, s, "%instantiate Rig") + meta(t, s, "%state Rig::Main #1") + meta(t, s, "%send Go") + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: A2") { + t.Errorf("entering through Both did not reach A2:\n%s", out) + } + meta(t, s, "%send Stop") + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: Idle") { + t.Errorf("leaving through Gather did not reach Idle:\n%s", out) + } +} + +// noDefaultEntryMachine has a composite state Work whose region holds a state but no initial +// pseudostate, and an entry point in that no transition leaves. +const noDefaultEntryMachine = ` + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const noDefaultEntryApplications = ` + ` + +// An entry point no transition leaves enters its state by the state's default entry, which, with no +// initial pseudostate in the region, enters the state and leaves the region inactive in v1 and in +// the runtime alike; the transition is written to the state and the report says so. +func TestDefaultEntryPointOnOwnerWithoutInitialEntersTheState(t *testing.T) { + r := migrateDocument(t, noDefaultEntryMachine, noDefaultEntryApplications) + for _, line := range []string{ + "transition first Idle accept Go then Work;", + "transition first Work accept Stop then Idle;", + "the region has no initial pseudostate: nothing enters it", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + if strings.Contains(string(r.Notation), "junction via;") { + t.Errorf("an entry point no transition leaves was written as a junction:\n%s", r.Notation) + } + wantNote(t, r, "_dIn", migrate.Mapped, "the region 'r' have no initial pseudostate, so the state is entered with them inactive, as v1 enters it") + wantNote(t, r, "_dT1", migrate.Mapped, "written to Work: no transition leaves the entry point 'via'") + s := session(t, r) + meta(t, s, "%instantiate Rig") + meta(t, s, "%state Rig::Main #1") + meta(t, s, "%send Go") + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: Work") || strings.Contains(out, "W1") { + t.Errorf("entering Work by its default entry did not leave its region inactive:\n%s", out) + } + meta(t, s, "%send Stop") + meta(t, s, "%step") + if out := meta(t, s, "%current"); !strings.Contains(out, "Current state: Idle") { + t.Errorf("Work was not left on Stop:\n%s", out) + } +} + +// outsideExitMachine has two composite states with one exit point each: Work's is reached from +// Idle, outside Work, besides from within; Self's by a local transition of Self itself. +const outsideExitMachine = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const outsideExitApplications = ` + ` + +// An exit point one transition reaches from outside its state is refused with that transition named +// however few transitions reach it: written as a junction, the runtime would enter the state to leave +// it, running entry and exit behaviors v1 never runs. One a local transition of the state itself +// reaches is a junction, the transition approximated as the local rule says. +func TestExitPointReachedFromOutsideIsRefused(t *testing.T) { + r := migrateDocument(t, outsideExitMachine, outsideExitApplications) + if n := strings.Count(string(r.Notation), "junction leave;"); n != 1 { + t.Errorf("want Self's exit point alone written as a junction, got %d:\n%s", n, r.Notation) + } + for _, line := range []string{"transition first Self accept Go then Self::leave;", "transition first Self::leave then Idle;"} { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + wantNote(t, r, "_oOut", migrate.Unmapped, "(_oT1) leads to the exit point from outside the state, from 'Idle'; v1 never enters the state, while the runtime would enter and leave it") + for _, id := range []string{"_owT1", "_oT1", "_oT2"} { + wantNote(t, r, id, migrate.Unmapped, "has no v2 form") + } + wantNote(t, r, "_oSOut", migrate.Mapped, "written as a junction of its state") + wantNote(t, r, "_oT3", migrate.Approximated, "a local transition is written external: the composite state Self exits and re-enters") + wantNote(t, r, "_oT4", migrate.Mapped, "") +} From 7826323d37886d599c2d8c1481cd4caae6cb5ae3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:31:40 +0000 Subject: [PATCH 07/12] fix(migrate): word the default-entry note of an entry point for one region Co-Authored-By: jason.han --- internal/translate/migrate/states.go | 2 +- tests/migrate/states_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index fb411036e2..81e0971022 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -221,7 +221,7 @@ func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { if len(out) == 0 { note := "no transition leaves the entry point, so entering through it enters " + describe(owner) + " by its default entry; a transition to it is written to the state" if without := regionsWithoutInitial(m.populatedRegions(owner)); len(without) > 0 { - note += "; the " + pluralRegion(len(without)) + " " + strings.Join(without, ", ") + " have no initial pseudostate, so the state is entered with them inactive, as v1 enters it" + note += "; no initial pseudostate starts the " + pluralRegion(len(without)) + " " + strings.Join(without, ", ") + ", which v1 too leaves inactive on entering the state" } return pointForm{defaultEntry: true, note: note} } diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 4b540b6b20..538acc756d 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -1191,7 +1191,7 @@ func TestDefaultEntryPointOnOwnerWithoutInitialEntersTheState(t *testing.T) { if strings.Contains(string(r.Notation), "junction via;") { t.Errorf("an entry point no transition leaves was written as a junction:\n%s", r.Notation) } - wantNote(t, r, "_dIn", migrate.Mapped, "the region 'r' have no initial pseudostate, so the state is entered with them inactive, as v1 enters it") + wantNote(t, r, "_dIn", migrate.Mapped, "no initial pseudostate starts the region 'r', which v1 too leaves inactive on entering the state") wantNote(t, r, "_dT1", migrate.Mapped, "written to Work: no transition leaves the entry point 'via'") s := session(t, r) meta(t, s, "%instantiate Rig") From ca751a7b33879a92dddcf86ee9023b892a4a3c87 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:40:21 +0000 Subject: [PATCH 08/12] fix(migrate): rename a region-listed connection point that clashes with a member of its state A connection point a tool lists in a region of an orthogonal state is written in the state's body, but the duplicate-name pass only saw the members of a state's own children and of its one inline region, so such a point could be declared beside an entry action, a directly owned connection point or another listed point of the same name. namespaceMembers now includes a state's connection points whichever region lists them, and treats the one region a body is written for as inline, so the later duplicate is renamed 'Name 2' as any sibling is. memberOwner returns the logical owner only for a state's points, so a state machine's region-listed points keep the path of the region they are declared in. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 2 +- internal/translate/migrate/migrate.go | 18 +++++++++++------- internal/translate/migrate/states.go | 25 +++++++++++++++++-------- tests/migrate/states_test.go | 21 +++++++++++++++++++++ 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 644a9e6f88..50ff8450e8 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -134,7 +134,7 @@ returned over the service yet. | Comment, Documentation | `doc` (first) / `comment`, HTML tags stripped | mapped | | Custom-profile stereotypes and tags | preserved as `/* applied stereotype «Name»: tag = value */` | mapped | | SysML stereotype tags without a v2 form (`Block.isEncapsulated`, `ValueType.unit`, …) | preserved as `/* «Name» tags with no v2 form: tag = value */` | approximated | -| Two members of one namespace with the same name (UML allows it, v2 does not) | the later one renamed `Name 2`, a state, pseudostate or history a machine's region puts beside its attributes included; a connection end named like a member of its connection def renamed `name2` | approximated | +| Two members of one namespace with the same name (UML allows it, v2 does not) | the later one renamed `Name 2`, a state, pseudostate or history a machine's region puts beside its attributes included, as is a connection point of a state whichever region a tool listed it in; a connection end named like a member of its connection def renamed `name2` | approximated | | Anonymous property with no v2 type | a `ref` named after its type, or `unnamed` | approximated | | Multiplicity bounds that are not natural numbers (a tool's `492x21` array dimensions) | omitted | approximated | | `NaN`/infinite real literals | comment | approximated | diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index aea76fd38a..65f90b568b 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -496,22 +496,26 @@ func (m *migration) distinguish(e *sysmlv1.Element) { } // namespaceMembers lists the children of e written as members of its v2 body: its -// own, and the named vertices of its one region, which v2 puts beside them. +// own, the named vertices of its one written region, which v2 puts beside them, +// and a state's connection points whichever region a tool listed them in. func namespaceMembers(e *sysmlv1.Element) []*sysmlv1.Element { var members []*sysmlv1.Element - inline := len(e.Owned("region")) == 1 + written := writtenRegions(e) + var inline *sysmlv1.Element + if len(written) == 1 { + inline = written[0] + } for _, c := range e.Children { switch { case c.Role == "region": - if !inline { - continue - } for _, v := range c.Owned("subvertex") { - if vertexBase(v) != "" { + if vertexBase(v) != "" && (c == inline || memberOwner(v) == e) { members = append(members, v) } } - members = append(members, c.Owned("transition")...) + if c == inline { + members = append(members, c.Owned("transition")...) + } case !ownerWritten(c.Role): members = append(members, c) } diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 81e0971022..9c87021760 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -184,10 +184,10 @@ func pointOwner(v *sysmlv1.Element) *sysmlv1.Element { return owner } -// memberOwner is the element whose body vertex v is written in: the owner of a -// connection point, however a tool listed it, else v's parent. +// memberOwner is the element whose body vertex v is written in: the state whose +// connection point it is, however a tool listed it, else v's parent. func memberOwner(v *sysmlv1.Element) *sysmlv1.Element { - if owner := pointOwner(v); owner != nil { + if owner := pointOwner(v); owner != nil && owner.Type == "State" { return owner } return v.Parent @@ -357,18 +357,27 @@ func trueLiteral(spec *sysmlv1.Element) bool { return spec != nil && spec.Type == "LiteralBoolean" && (spec.Attrs["value"] == "true" || spec.Attrs["value"] == "1") } -// populatedRegions returns the regions of a machine or state that hold a vertex -// of their own; an empty one, or one listing only its state's connection points, -// has nothing to enter, so it is skipped rather than written as a sub-state no entry starts. +// populatedRegions returns writtenRegions and reports the regions it leaves out: an +// empty one, or one listing only its state's connection points, has nothing to +// enter, so it is skipped rather than written as a sub-state no entry starts. func (m *migration) populatedRegions(owner *sysmlv1.Element) []*sysmlv1.Element { - var out []*sysmlv1.Element for _, r := range owner.Owned("region") { switch { case len(r.Owned("subvertex")) == 0: m.add(r, Skipped, "", unreferencedNote+": the region holds no vertex, so nothing enters it and no state is written for it") case pointsOnly(r, owner): m.add(r, Skipped, "", unreferencedNote+": the region lists only connection points of its state, which are written in the state's body, so nothing enters it and no state is written for it") - default: + } + } + return writtenRegions(owner) +} + +// writtenRegions lists the regions of a machine or state a body is written for: +// those holding a vertex of their own. +func writtenRegions(owner *sysmlv1.Element) []*sysmlv1.Element { + var out []*sysmlv1.Element + for _, r := range owner.Owned("region") { + if len(r.Owned("subvertex")) > 0 && !pointsOnly(r, owner) { out = append(out, r) } } diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 538acc756d..64bb8b3fca 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -1275,3 +1275,24 @@ func TestExitPointReachedFromOutsideIsRefused(t *testing.T) { wantNote(t, r, "_oT3", migrate.Approximated, "a local transition is written external: the composite state Self exits and re-enters") wantNote(t, r, "_oT4", migrate.Mapped, "") } + +// An entry point a tool lists in a region of an orthogonal state is a member of the state's +// body, so it is renamed when the body has a member of its name, as a sibling would be. +func TestRegionListedPointKeepsClearOfTheStatesMembers(t *testing.T) { + entry := ` + ` + xmi := strings.Replace(regionListedPoints, ``, entry, 1) + r := migrateDocument(t, xmi, regionListedPointsApplications) + for _, line := range []string{ + "entry action Both", + "fork 'Both 2';", + "transition first Idle accept Go then Sync::'Both 2';", + "transition first Sync::'Both 2' then A2;", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + wantNote(t, r, "_rBoth", migrate.Approximated, "written as Both 2 since a sibling is also named Both") + session(t, r) +} From 222f208e2331dc744808f365f13dbe2533f566b7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:15:51 +0000 Subject: [PATCH 09/12] fix(migrate): let a connection point written as no member take no name from its state The duplicate-name pass ran over a state's connection points before their form was settled, so a point entered by default entry or refused, which declares nothing, still made a later member of its name 'Name 2'. A state's connection points are now left out of that pass and distinguished in namePoints, once their form is known: a written point whose name a member of the body bears yields to it with the same 'Name 2' policy, shared through distinct. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 2 +- internal/translate/migrate/migrate.go | 35 ++++++++++++++++----------- internal/translate/migrate/states.go | 17 +++++++++++-- tests/migrate/states_test.go | 30 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 50ff8450e8..55a5119f10 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -134,7 +134,7 @@ returned over the service yet. | Comment, Documentation | `doc` (first) / `comment`, HTML tags stripped | mapped | | Custom-profile stereotypes and tags | preserved as `/* applied stereotype «Name»: tag = value */` | mapped | | SysML stereotype tags without a v2 form (`Block.isEncapsulated`, `ValueType.unit`, …) | preserved as `/* «Name» tags with no v2 form: tag = value */` | approximated | -| Two members of one namespace with the same name (UML allows it, v2 does not) | the later one renamed `Name 2`, a state, pseudostate or history a machine's region puts beside its attributes included, as is a connection point of a state whichever region a tool listed it in; a connection end named like a member of its connection def renamed `name2` | approximated | +| Two members of one namespace with the same name (UML allows it, v2 does not) | the later one renamed `Name 2`, a state, pseudostate or history a machine's region puts beside its attributes included, as is a written connection point of a state whichever region a tool listed it in, while one written as no member takes no name; a connection end named like a member of its connection def renamed `name2` | approximated | | Anonymous property with no v2 type | a `ref` named after its type, or `unnamed` | approximated | | Multiplicity bounds that are not natural numbers (a tool's `492x21` array dimensions) | omitted | approximated | | `NaN`/infinite real literals | comment | approximated | diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index 65f90b568b..b00e56194c 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -486,36 +486,43 @@ func (m *migration) distinguish(e *sysmlv1.Element) { seen[c.Name] = true continue } - name := c.Name - for i := 2; seen[name] || m.nameTaken(e, name); i++ { - name = fmt.Sprintf("%s %d", c.Name, i) - } - seen[name] = true - m.names[c] = name + m.names[c] = m.distinct(e, seen, c.Name) + } +} + +// distinct gives a member of e named name, which a sibling in seen already bears, +// the first `name 2`, `name 3`, … no member of e has, and marks it seen. +func (m *migration) distinct(e *sysmlv1.Element, seen map[string]bool, name string) string { + fresh := name + for i := 2; seen[fresh] || m.nameTaken(e, fresh); i++ { + fresh = fmt.Sprintf("%s %d", name, i) } + seen[fresh] = true + return fresh } // namespaceMembers lists the children of e written as members of its v2 body: its -// own, the named vertices of its one written region, which v2 puts beside them, -// and a state's connection points whichever region a tool listed them in. +// own, and the named vertices of its one written region, which v2 puts beside them. +// A state's connection points are left to namePoints, which knows which are written. func namespaceMembers(e *sysmlv1.Element) []*sysmlv1.Element { var members []*sysmlv1.Element - written := writtenRegions(e) var inline *sysmlv1.Element - if len(written) == 1 { + if written := writtenRegions(e); len(written) == 1 { inline = written[0] } for _, c := range e.Children { switch { case c.Role == "region": + if c != inline { + continue + } for _, v := range c.Owned("subvertex") { - if vertexBase(v) != "" && (c == inline || memberOwner(v) == e) { + if vertexBase(v) != "" && memberOwner(v) != e { members = append(members, v) } } - if c == inline { - members = append(members, c.Owned("transition")...) - } + members = append(members, c.Owned("transition")...) + case c.Role == "connectionPoint" && e.Type == "State": case !ownerWritten(c.Role): members = append(members, c) } diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 9c87021760..20601c6615 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -148,12 +148,25 @@ func pointsOnly(r, owner *sysmlv1.Element) bool { // namePoints settles how each connection point of composite state v is written // and names those written as members of its body, whose names used lists. func (m *migration) namePoints(v *sysmlv1.Element, used map[string]bool) { + // seen holds the names of the body's other members; a written point that bears one yields. + seen := map[string]bool{} + for _, c := range namespaceMembers(v) { + if name := m.nameOf(c); name != "" { + seen[name] = true + } + } for _, cp := range m.connectionPoints(v) { f := m.statePointForm(cp, v) m.points[cp] = f - if f.kw != "" { - m.nameVertex(cp, v, used) + if f.kw == "" { + continue + } + if name := m.nameOf(cp); name != "" && seen[name] { + m.names[cp] = m.distinct(v, seen, name) + } else if name != "" { + seen[name] = true } + m.nameVertex(cp, v, used) } } diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 64bb8b3fca..91948b2d81 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -1296,3 +1296,33 @@ func TestRegionListedPointKeepsClearOfTheStatesMembers(t *testing.T) { wantNote(t, r, "_rBoth", migrate.Approximated, "written as Both 2 since a sibling is also named Both") session(t, r) } + +// A connection point that is written as no member takes no name from the state's body: +// an entry behavior named like an entry point nothing leaves keeps its name. +func TestUnwrittenPointLeavesTheStatesMembersTheirNames(t *testing.T) { + xmi := regionListedPoints + for _, leaving := range []string{ + ``, + ``, + } { + xmi = strings.Replace(xmi, leaving, "", 1) + } + xmi = strings.Replace(xmi, ` + `, ` + + `, 1) + r := migrateDocument(t, xmi, regionListedPointsApplications) + for _, line := range []string{ + "entry action Both", + "transition first Idle accept Go then Sync;", + } { + if !strings.Contains(string(r.Notation), line) { + t.Errorf("missing %q in:\n%s", line, r.Notation) + } + } + if strings.Contains(string(r.Notation), "Both 2") { + t.Errorf("a member is renamed for a connection point that is written as no member:\n%s", r.Notation) + } + wantNote(t, r, "_rBoth", migrate.Mapped, "no transition leaves the entry point") + session(t, r) +} From 3835977f419da17b614bd372835d194d395ff27f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:35:03 +0000 Subject: [PATCH 10/12] fix(migrate): rename a clashing connection point past written members only A written point that yields its name took the first suffix no child of the state bore, so a connection point written as no member could still hold a suffix. It now takes the first name neither a written member of the body nor a synthesized name of the body has. Co-Authored-By: jason.han --- internal/translate/migrate/migrate.go | 10 +++++----- internal/translate/migrate/states.go | 6 ++++-- tests/migrate/states_test.go | 7 +++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index b00e56194c..5d4e1442e1 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -486,15 +486,15 @@ func (m *migration) distinguish(e *sysmlv1.Element) { seen[c.Name] = true continue } - m.names[c] = m.distinct(e, seen, c.Name) + m.names[c] = distinct(seen, func(n string) bool { return m.nameTaken(e, n) }, c.Name) } } -// distinct gives a member of e named name, which a sibling in seen already bears, -// the first `name 2`, `name 3`, … no member of e has, and marks it seen. -func (m *migration) distinct(e *sysmlv1.Element, seen map[string]bool, name string) string { +// distinct gives a member named name, which a sibling in seen already bears, the +// first `name 2`, `name 3`, … neither seen nor taken, and marks it seen. +func distinct(seen map[string]bool, taken func(string) bool, name string) string { fresh := name - for i := 2; seen[fresh] || m.nameTaken(e, fresh); i++ { + for i := 2; seen[fresh] || taken(fresh); i++ { fresh = fmt.Sprintf("%s %d", name, i) } seen[fresh] = true diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 20601c6615..23f39edfe1 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -148,13 +148,15 @@ func pointsOnly(r, owner *sysmlv1.Element) bool { // namePoints settles how each connection point of composite state v is written // and names those written as members of its body, whose names used lists. func (m *migration) namePoints(v *sysmlv1.Element, used map[string]bool) { - // seen holds the names of the body's other members; a written point that bears one yields. + // seen holds the names of the body's other members; a written point that bears one + // yields to it, taking a name no member and no synthesized name of the body has. seen := map[string]bool{} for _, c := range namespaceMembers(v) { if name := m.nameOf(c); name != "" { seen[name] = true } } + synthesized := func(n string) bool { return m.taken[v][n] } for _, cp := range m.connectionPoints(v) { f := m.statePointForm(cp, v) m.points[cp] = f @@ -162,7 +164,7 @@ func (m *migration) namePoints(v *sysmlv1.Element, used map[string]bool) { continue } if name := m.nameOf(cp); name != "" && seen[name] { - m.names[cp] = m.distinct(v, seen, name) + m.names[cp] = distinct(seen, synthesized, name) } else if name != "" { seen[name] = true } diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 91948b2d81..1e912b935d 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -1277,10 +1277,12 @@ func TestExitPointReachedFromOutsideIsRefused(t *testing.T) { } // An entry point a tool lists in a region of an orthogonal state is a member of the state's -// body, so it is renamed when the body has a member of its name, as a sibling would be. +// body, so it is renamed when the body has a member of its name, as a sibling would be; +// a point written as no member, here one named like the first free name, is no obstacle. func TestRegionListedPointKeepsClearOfTheStatesMembers(t *testing.T) { entry := ` - ` + + ` xmi := strings.Replace(regionListedPoints, ``, entry, 1) r := migrateDocument(t, xmi, regionListedPointsApplications) for _, line := range []string{ @@ -1294,6 +1296,7 @@ func TestRegionListedPointKeepsClearOfTheStatesMembers(t *testing.T) { } } wantNote(t, r, "_rBoth", migrate.Approximated, "written as Both 2 since a sibling is also named Both") + wantNote(t, r, "_rBoth2", migrate.Mapped, "no transition leaves the entry point") session(t, r) } From 9754bc1fed0e091bd569992993e3240e8a2b3c02 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:38:19 +0000 Subject: [PATCH 11/12] fix(migrate): validate an exit point's outgoing route before writing it as a junction A transition leaving a composite state's exit point with a trigger, to no target, into a history pseudostate, back to the state or to a vertex within it, and an exit point no transition leaves, refuse the point with the shape named: the runtime follows a junction at once, and a route from a member of the state to the state itself stays in it without running its exit or entry. Co-Authored-By: jason.han --- docs/reference/sysml-v1-migration.md | 6 +- internal/translate/migrate/states.go | 28 ++++++ tests/migrate/states_test.go | 122 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 55a5119f10..12e5b2899d 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -179,7 +179,7 @@ returned over the service yet. | Pseudostate entryPoint, exitPoint on a state machine | a `state` of the submachine's `state def`; a transition into an entry point continues by the entry point's own transition, a transition out of an exit point leaves the submachine state | mapped | | Pseudostate entryPoint on a composite State (`State.connectionPoint`) | `junction x;` of the state, a transition into it written `then Work::x` by path; the runtime runs the state's entry behavior, then the junction's outgoing transition, then the target's entries, in one run-to-completion step. One whose outgoing transitions each start a different orthogonal region is `fork x;`; one no transition leaves is the state's default entry, and the transition is written to the state | mapped | | Pseudostate exitPoint on a composite State | `junction x;` of the state, a transition out of it written `first Work::x` by path; the runtime runs the transition into it (its source's exits, its effect), the state's exit behavior, then the outgoing transition. One reached from several orthogonal regions is `join x;`, left through when every region's transition has fired. A connection point a tool lists among a region's vertices belongs to the state all the same; a region listing nothing else is skipped, not written as a region of a `parallel` state | mapped | -| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or whose outgoing transition has a trigger, or several of whose outgoing transitions start the same region; an exit point reached from outside its state, or one several regions reach that is also reached twice from one region, from the state's own local transition, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | +| Entry point leading straight to an exit point of the same state, back to the state itself, out of the state, into a history pseudostate or to no target, or whose outgoing transition has a trigger, or several of whose outgoing transitions start the same region; an exit point reached from outside its state, one no transition leaves or whose outgoing transition has a trigger, leads back to the state or into it, into a history pseudostate or to no target, or one several regions reach that is also reached twice from one region, from the state's own local transition, or from a pseudostate; a connection point route into a history pseudostate | refused with the shape named | unmapped | | Pseudostate exitPoint on a region, terminate | a transition into it is written to `done` | approximated | | ConnectionPointReference on a submachine state | the transition is written to `s.` / from `s.`, the submachine's state named by its path | mapped | | Transition between regions or nesting levels (source or target not a sibling) | the transition names the far end by its path, `Work::Run`; a local transition into a substate of its source is written external, so the composite state exits and re-enters | mapped (local into own substate: approximated) | @@ -472,7 +472,9 @@ behavior; so is an entry point whose transition leads back to the state itself ( its default entry where the runtime would leave and re-enter it), out of the state, on into a history pseudostate or to no target, one whose transition has a trigger (a junction's transition is followed at once, not on an event), and any point whose transitions do not form one of the -shapes above. A guard on that transition is kept: UML evaluates a junction's guards with the +shapes above. An exit point's outgoing transition is held to the same: one with a trigger, to +no target, into a history, back to the state or to a vertex within it refuses the point, as does +an exit point no transition leaves, since the runtime would halt at the junction. A guard on that transition is kept: UML evaluates a junction's guards with the rest of the compound transition's before it fires, not after entering the state, and the runtime evaluates the junction's guard when it selects the transition; where UML leaves the compound transition disabled by a false guard, the runtime reports it, as at any junction. diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index 818787bf36..548a0f2f6a 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -291,6 +291,15 @@ func (m *migration) entryBranchWhy(t, owner *sysmlv1.Element) string { } func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { + out := m.outgoing[v] + if len(out) == 0 { + return pointForm{why: "no transition leaves the exit point, so a transition into it leaves " + describe(owner) + " for nowhere; the runtime would halt at the junction"} + } + for _, t := range out { + if why := m.exitBranchWhy(t, owner); why != "" { + return pointForm{why: describe(t) + " leads from the exit point " + why} + } + } // in holds the transitions from within the state that shape the point; one from the // state itself is its local transition, one from its entry point is refused with that. var in, local []*sysmlv1.Element @@ -327,6 +336,25 @@ func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { return pointForm{kw: "join", note: "written as a join of its state, which its regions leave through together; the transitions into the join run, then the state's exit behavior, then the transition leaving it"} } +// exitBranchWhy says why transition t, leaving an exit point of owner, keeps the point from +// being written: it has a trigger, or its target is missing, a history, or owner or a vertex within it. +func (m *migration) exitBranchWhy(t, owner *sysmlv1.Element) string { + tgt := m.model.Ref(t, "target") + switch { + case len(t.Owned("trigger")) > 0: + return "with a trigger, which no transition out of a pseudostate takes; the runtime would follow it without waiting for the event" + case tgt == nil: + return "to no target" + case tgt == owner: + return "back to the state itself, which v1 leaves and re-enters by its default entry while the runtime, moving from a member of the state to the state, would stay in it, running neither its exit nor its entry" + case pseudoKind(tgt) == "shallowHistory" || pseudoKind(tgt) == "deepHistory": + return "on into the history pseudostate " + describe(tgt) + ", which the runtime does not follow from a junction" + case regionWithin(tgt, owner) != nil || pointOwner(tgt) == owner: + return "back into the state, to " + describe(tgt) + ", which v1 leaves and re-enters while the runtime, moving between members of the state, would stay in it" + } + return "" +} + // regionsCrossed lists the distinct regions of owner the given ends of the // transitions lie in; an end outside owner counts as none. func (m *migration) regionsCrossed(transitions []*sysmlv1.Element, owner *sysmlv1.Element, role string) []*sysmlv1.Element { diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 1e912b935d..a86ba7e8c8 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -1208,6 +1208,128 @@ func TestDefaultEntryPointOnOwnerWithoutInitialEntersTheState(t *testing.T) { } } +// exitShapesMachine has six composite states with one exit point each, every one reached from a +// state within by a triggered transition: Wait's leaves by a triggered transition, Bare's to no +// target, Back's into a history, Self's to Self itself, Inner's back to a state within, Dead's not at all. +const exitShapesMachine = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const exitShapesApplications = ` + ` + +// An exit point whose outgoing route waits on a trigger, reaches no target, runs on into a +// history, leads back to the state or into it, or that no transition leaves is refused with that +// route named, the transitions through it with it, and no junction is written. +func TestExitPointRoutesAreRefusedPrecisely(t *testing.T) { + r := migrateDocument(t, exitShapesMachine, exitShapesApplications) + if strings.Contains(string(r.Notation), "junction leave;") { + t.Errorf("a refused exit point was written as a junction:\n%s", r.Notation) + } + wantNote(t, r, "_xaOut", migrate.Unmapped, "(_xT1) leads from the exit point with a trigger, which no transition out of a pseudostate takes; the runtime would follow it without waiting for the event") + wantNote(t, r, "_xbOut", migrate.Unmapped, "(_xT2) leads from the exit point to no target") + wantNote(t, r, "_xcOut", migrate.Unmapped, "(_xT3) leads from the exit point on into the history pseudostate 'H', which the runtime does not follow from a junction") + wantNote(t, r, "_xdOut", migrate.Unmapped, "(_xT4) leads from the exit point back to the state itself, which v1 leaves and re-enters by its default entry while the runtime, moving from a member of the state to the state, would stay in it, running neither its exit nor its entry") + wantNote(t, r, "_xeOut", migrate.Unmapped, "(_xeT2) leads from the exit point back into the state, to 'E2', which v1 leaves and re-enters while the runtime, moving between members of the state, would stay in it") + wantNote(t, r, "_xfOut", migrate.Unmapped, "no transition leaves the exit point, so a transition into it leaves 'Dead' for nowhere; the runtime would halt at the junction") + for _, id := range []string{"_xaT1", "_xT1", "_xbT1", "_xcT1", "_xdT1", "_xT4", "_xeT1", "_xeT2", "_xfT1"} { + wantNote(t, r, id, migrate.Unmapped, "has no v2 form") + } + wantNote(t, r, "_xT2", migrate.Unmapped, "lacks an end") + wantNote(t, r, "_xT3", migrate.Unmapped, "does not follow a transition from a exitPoint pseudostate on into the history pseudostate") +} + // outsideExitMachine has two composite states with one exit point each: Work's is reached from // Idle, outside Work, besides from within; Self's by a local transition of Self itself. const outsideExitMachine = ` From f77f91e0f94d2b31e7e9ebf521a4d78e1adc178a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:09:06 +0000 Subject: [PATCH 12/12] fix(runtime): record a default history route's segments when it ends at a terminate Co-Authored-By: jason.han --- internal/exec/runtime/debug_api_test.go | 28 +++++++++++++++++++++---- internal/exec/runtime/state_executor.go | 2 +- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/exec/runtime/debug_api_test.go b/internal/exec/runtime/debug_api_test.go index fa1287939f..456b6baffc 100644 --- a/internal/exec/runtime/debug_api_test.go +++ b/internal/exec/runtime/debug_api_test.go @@ -1484,11 +1484,13 @@ func TestFiredTransitionsLogsSegmentsIntoAChoice(t *testing.T) { // A history entered before its owner has run takes its default transition, and // that route is logged after the transition into the history: straight to the -// default state, or through a choice with each segment on the way. +// default state, through a choice with each segment on the way, or into a +// terminate that ends the machine. func TestFiredTransitionsLogsDefaultHistoryRoute(t *testing.T) { for _, tc := range []struct { name, src string want []string + ended bool }{ {"direct", `package test { state Machine { @@ -1503,7 +1505,7 @@ func TestFiredTransitionsLogsDefaultHistoryRoute(t *testing.T) { } transition first init accept go then previous; } - }`, []string{"->init", "init->previous", "previous->idle"}}, + }`, []string{"->init", "init->previous", "previous->idle"}, false}, {"choice", `package test { state Machine { attribute priority : Integer = 2; @@ -1520,7 +1522,21 @@ func TestFiredTransitionsLogsDefaultHistoryRoute(t *testing.T) { } transition first init accept go then previous; } - }`, []string{"->init", "init->previous", "previous->route", "route->idle"}}, + }`, []string{"->init", "init->previous", "previous->route", "route->idle"}, false}, + {"terminate", `package test { + state Machine { + entry; then init; + state init; + state running { + history previous; + state idle; + action stop terminate; + transition first previous then stop; + transition first idle accept work then stop; + } + transition first init accept go then previous; + } + }`, []string{"->init", "init->previous", "previous->stop"}, true}, } { t.Run(tc.name, func(t *testing.T) { ctx, sym := loadState(t, tc.src, "Machine") @@ -1533,7 +1549,11 @@ func TestFiredTransitionsLogsDefaultHistoryRoute(t *testing.T) { if err := exec.ProcessNextEvent(); err != nil { t.Fatalf("ProcessNextEvent: %v", err) } - if got := activeStateNames(exec); !strings.Contains(got, "idle") { + if tc.ended { + if exec.State() != StateTerminated { + t.Fatalf("state = %v, want the machine ended at the terminate", exec.State()) + } + } else if got := activeStateNames(exec); !strings.Contains(got, "idle") { t.Fatalf("active = %s, want idle", got) } if got := firedNames(exec); !slices.Equal(got, tc.want) { diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 56c65b7a8b..e805ee499e 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -2424,13 +2424,13 @@ func (e *StateExecutor) moveToHistory(trans *lower.Transition, currentState *ast if err != nil { return err } + e.noteFired(r.segments...) if r.terminate != nil { return e.terminateAt(trans, fromName, r, r.effects(e.graph), e.descendantChain(below, e.graph.TerminateOwner[r.terminate])) } if err := e.runEffects(r.effects(e.graph), e.descendantChain(below, r.target)); err != nil { return err } - e.noteFired(r.segments...) return e.enterBelow(trans, fromName, below, r.target, nil) }