From 6b7880dd4d0df594129df6a3fe922be84d3cb9b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:12:26 +0000 Subject: [PATCH 1/3] fix(view): label transition triggers by the name their signal or operation ends in Co-Authored-By: jason.han --- .../view-trigger-end-names.fixed.md | 1 + internal/ir/view/behavior.go | 48 +++++++++++++++---- internal/ir/view/edge_label_test.go | 43 +++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 changes/unreleased/view-trigger-end-names.fixed.md diff --git a/changes/unreleased/view-trigger-end-names.fixed.md b/changes/unreleased/view-trigger-end-names.fixed.md new file mode 100644 index 000000000..c2c1e476f --- /dev/null +++ b/changes/unreleased/view-trigger-end-names.fixed.md @@ -0,0 +1 @@ +- **Transition triggers are labelled by the name their signal or operation ends in.** A rendered state or action view wrote an `accept` trigger as its source text, so a migrated transition accepting `TMT::'02 JPL'::…::Control::'Post-Segment Exchange Alignment'` carried that whole path across the drawing. The DOT, Mermaid, PlantUML and text forms now head the trigger by its end name — `accept 'Post-Segment Exchange Alignment'`, `accept msg : Halt`, `accept setSpeed(value)` — the way a node's type is headed; time and change events keep their written text. diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index 949859d69..ecbc0f8e6 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -8,6 +8,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/ir/lower" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // maxBehaviorDepth bounds how deep a nested action usage is lowered, so a @@ -303,12 +304,23 @@ func behaviorNames(behaviors []lower.StateBehavior) string { return strings.Join(names, ", ") } -// triggerLabel is the event a transition waits for, as written when the source -// is at hand, else what kind of event it is. +// triggerLabel is the event a transition waits for: an accepted signal or called +// operation by the name it ends in, as a type is; a time or change event as +// written when the source is at hand; else what kind of event it is. func (r *Renderer) triggerLabel(doc string, trigger ast.Node) string { if trigger == nil { return "" } + switch event := trigger.(type) { + case *ast.AcceptEvent: + if event.SignalType != nil { + return joinNonEmpty(joinNonEmpty("accept", payloadHead(event.Payload)), endName(event.SignalType)) + } + case *ast.CallEvent: + if event.Operation != nil { + return "accept " + endName(event.Operation) + callParameters(event.Parameters) + } + } if text := r.nodeText(doc, trigger); text != "" { return text } @@ -325,19 +337,39 @@ func (r *Renderer) triggerLabel(doc string, trigger ast.Node) string { if event.Subsets != nil { return "accept :> " + notationName(qualifiedText(event.Subsets)) } - if event.SignalType != nil { - return "accept " + notationName(qualifiedText(event.SignalType)) - } return "accept event" case *ast.CallEvent: - if event.Operation != nil { - return "accept " + notationName(qualifiedText(event.Operation)) - } return "call event" } return "event" } +// payloadHead is the payload parameter an accept declares, `msg :` for +// `accept msg : Warning`, and "" when the accept names none. +func payloadHead(payload *ast.Usage) string { + if payload == nil || payload.Ident.Name == "" { + return "" + } + return nameText(payload.Ident.Name) + " :" +} + +// endName is the name a qualified reference ends in, quoted as the notation does. +func endName(name *ast.QualifiedName) string { + return source.ReferenceEndNames(notationName(qualifiedText(name))) +} + +// callParameters writes a call trigger's argument names, `(speed)`, "" for none. +func callParameters(parameters []ast.NameSegment) string { + if len(parameters) == 0 { + return "" + } + names := make([]string, len(parameters)) + for i, parameter := range parameters { + names[i] = nameText(parameter.Text) + } + return "(" + strings.Join(names, ", ") + ")" +} + // nodeText is the notation a node was written in, collapsed to one line, and "" // when the rendering holds no source for it. func (r *Renderer) nodeText(doc string, node ast.Node) string { diff --git a/internal/ir/view/edge_label_test.go b/internal/ir/view/edge_label_test.go index 1a8a0b189..dc5624ced 100644 --- a/internal/ir/view/edge_label_test.go +++ b/internal/ir/view/edge_label_test.go @@ -80,6 +80,49 @@ func TestBindingIsAnInterconnectionEdge(t *testing.T) { } } +// A trigger names its signal or operation by the name it ends in, as a type is +// headed, however far the source qualifies it; its payload name and call +// arguments are kept, and time and change events keep their written text. +func TestTriggerLabelsHeadTheirSignalByItsEndName(t *testing.T) { + model := `package Triggers { + package Signals { package 'APS Internal' { attribute def 'Go Now'; attribute def Halt; } } + action def setSpeed { in value : ScalarValues::Real; } + state def Machine { + entry; then idle; + state idle; + state moving; + state stopped; + transition first idle accept Triggers::Signals::'APS Internal'::'Go Now' then moving; + transition first moving accept msg : Signals::'APS Internal'::Halt then stopped; + transition first stopped accept Triggers::setSpeed(value) then moving; + transition first moving accept after 5 then idle; + } + view machineView : StandardViewDefinitions::StateTransitionView { expose Machine; } +} +` + r, idx := loadSources(t, []string{"triggers.sysml"}, [][]byte{[]byte(model)}) + rendering, err := r.Render(lookup(t, idx, "Triggers::machineView")) + if err != nil { + t.Fatalf("render: %v", err) + } + text := rendering.Text() + for _, want := range []string{ + "idle -> moving: accept 'Go Now'", + "moving -> stopped: accept msg : Halt", + "stopped -> moving: accept setSpeed(value)", + "moving -> idle: after 5", + } { + if !strings.Contains(text, want) { + t.Errorf("rendering lacks %q:\n%s", want, text) + } + } + for _, edge := range rendering.Edges { + if strings.Contains(edge.Label, "::") { + t.Errorf("a trigger label keeps its qualification: %q", edge.Label) + } + } +} + // A name labels an edge only when the edge has no text of its own: a trigger, guard, // pin or payload takes the label and the name is left out, given or synthesized. func TestEdgeLabelsYieldToTheEdgesOwnText(t *testing.T) { From eb00f5a4fc8910267ca756b117a22195e32218b6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:16:16 +0000 Subject: [PATCH 2/3] docs(views): trigger labels head their signal by its end name Co-Authored-By: jason.han --- docs/project/view-rendering-forms.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 392840eb5..9b20d7f25 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -121,6 +121,13 @@ the source had none (`MigrationMetadata::SynthesizedName`) never becomes a label text of its own and such a name is drawn unlabelled, as its source drew it. The rule is one place, so the text, Mermaid, DOT and PlantUML forms label an edge alike. +A trigger names its signal or operation the way a node's type is named, by the name the reference +ends in (`triggerLabel` in `behavior.go`): `accept Signals::'APS Internal'::'Go Now'` reads +`accept 'Go Now'`, a named payload keeps its name (`accept msg : Halt`) and a call trigger its +arguments (`accept setSpeed(value)`), so a transition a v1 migration wrote with the signal's whole +path does not carry that path across the drawing. A time or change event, and an accept of an +event feature (`accept :> shutDown`), keep their written text. + ## Why DOT next to Mermaid Mermaid was chosen first because it draws where models are read — Markdown, documentation sites, From 5db73a5c2153042e8ee666124399220404042de1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:15:47 +0000 Subject: [PATCH 3/3] fix(view): take a trigger's end name from its last segment; keep () on parameterless calls Co-Authored-By: jason.han --- docs/project/view-rendering-forms.md | 2 +- internal/ir/view/behavior.go | 13 ++++++------- internal/ir/view/edge_label_test.go | 10 ++++++++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 9b20d7f25..dc3a65135 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -124,7 +124,7 @@ so the text, Mermaid, DOT and PlantUML forms label an edge alike. A trigger names its signal or operation the way a node's type is named, by the name the reference ends in (`triggerLabel` in `behavior.go`): `accept Signals::'APS Internal'::'Go Now'` reads `accept 'Go Now'`, a named payload keeps its name (`accept msg : Halt`) and a call trigger its -arguments (`accept setSpeed(value)`), so a transition a v1 migration wrote with the signal's whole +arguments (`accept setSpeed(value)`, `accept halt()` — the parentheses tell a call from a signal), so a transition a v1 migration wrote with the signal's whole path does not carry that path across the drawing. A time or change event, and an accept of an event feature (`accept :> shutDown`), keep their written text. diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index ecbc0f8e6..2167bf87b 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -8,7 +8,6 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/ir/lower" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" - "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // maxBehaviorDepth bounds how deep a nested action usage is lowered, so a @@ -353,16 +352,16 @@ func payloadHead(payload *ast.Usage) string { return nameText(payload.Ident.Name) + " :" } -// endName is the name a qualified reference ends in, quoted as the notation does. +// endName is the last segment of a qualified reference, quoted as the notation does. func endName(name *ast.QualifiedName) string { - return source.ReferenceEndNames(notationName(qualifiedText(name))) + if name == nil || len(name.Parts) == 0 { + return "" + } + return nameText(name.Parts[len(name.Parts)-1].Text) } -// callParameters writes a call trigger's argument names, `(speed)`, "" for none. +// callParameters writes a call trigger's argument list, `(speed)`, `()` for none. func callParameters(parameters []ast.NameSegment) string { - if len(parameters) == 0 { - return "" - } names := make([]string, len(parameters)) for i, parameter := range parameters { names[i] = nameText(parameter.Text) diff --git a/internal/ir/view/edge_label_test.go b/internal/ir/view/edge_label_test.go index dc5624ced..23c571ea8 100644 --- a/internal/ir/view/edge_label_test.go +++ b/internal/ir/view/edge_label_test.go @@ -85,17 +85,21 @@ func TestBindingIsAnInterconnectionEdge(t *testing.T) { // arguments are kept, and time and change events keep their written text. func TestTriggerLabelsHeadTheirSignalByItsEndName(t *testing.T) { model := `package Triggers { - package Signals { package 'APS Internal' { attribute def 'Go Now'; attribute def Halt; } } + package Signals { package 'APS Internal' { attribute def 'Go Now'; attribute def Halt; attribute def 'Go::Now'; } } action def setSpeed { in value : ScalarValues::Real; } + action def halt; state def Machine { entry; then idle; state idle; state moving; state stopped; + state parked; transition first idle accept Triggers::Signals::'APS Internal'::'Go Now' then moving; transition first moving accept msg : Signals::'APS Internal'::Halt then stopped; transition first stopped accept Triggers::setSpeed(value) then moving; transition first moving accept after 5 then idle; + transition first idle accept Signals::'APS Internal'::'Go::Now' then parked; + transition first parked accept Triggers::halt() then stopped; } view machineView : StandardViewDefinitions::StateTransitionView { expose Machine; } } @@ -111,13 +115,15 @@ func TestTriggerLabelsHeadTheirSignalByItsEndName(t *testing.T) { "moving -> stopped: accept msg : Halt", "stopped -> moving: accept setSpeed(value)", "moving -> idle: after 5", + "idle -> parked: accept 'Go::Now'", + "parked -> stopped: accept halt()", } { if !strings.Contains(text, want) { t.Errorf("rendering lacks %q:\n%s", want, text) } } for _, edge := range rendering.Edges { - if strings.Contains(edge.Label, "::") { + if strings.Contains(edge.Label, "Triggers::") || strings.Contains(edge.Label, "Signals::") { t.Errorf("a trigger label keeps its qualification: %q", edge.Label) } }