From 5e11d09d77f75d7e6787eec96a0e71ff940c960c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:48:47 +0000 Subject: [PATCH 1/8] fix(migrate): avoid inherited occurrence names in sequences Co-Authored-By: jason.han --- .../sequence-inherited-names.fixed.md | 2 + internal/translate/migrate/interaction.go | 27 ++++++- .../interaction_names_internal_test.go | 27 +++++++ internal/translate/migrate/migrate.go | 2 + tests/migrate/sequence_names_test.go | 70 +++++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 changes/unreleased/sequence-inherited-names.fixed.md create mode 100644 internal/translate/migrate/interaction_names_internal_test.go create mode 100644 tests/migrate/sequence_names_test.go diff --git a/changes/unreleased/sequence-inherited-names.fixed.md b/changes/unreleased/sequence-inherited-names.fixed.md new file mode 100644 index 0000000000..64a68699b6 --- /dev/null +++ b/changes/unreleased/sequence-inherited-names.fixed.md @@ -0,0 +1,2 @@ +- SysML v1 sequence migration avoids inherited occurrence member names when naming lifelines, + messages and parameters, keeping message ends and diagram layout references consistent. diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index dbd22bb3b2..0240ee5f0a 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Open-MBEE/OpenSysML/internal/translate/xmi/sysmlv1" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) const ( @@ -15,6 +16,27 @@ const ( refersToWord = "refers to " ) +func inheritedOccurrenceNames() map[string]bool { + idx := libs.SharedBase() + names, seen := map[string]bool{}, map[string]bool{} + queue := []string{"Occurrences::Occurrence"} + for len(queue) > 0 { + t := queue[0] + queue = queue[1:] + if seen[t] { + continue + } + seen[t] = true + for _, member := range idx.LookupDirectChildrenFrom(t, "") { + if member.Name != "" { + names[member.Name] = true + } + } + queue = append(queue, librarySupers(idx, t)...) + } + return names +} + // scenario is an interaction resolved to the steps its v2 form performs in // occurrence order: signal sends, operation calls, replies and combined fragments. type scenario struct { @@ -171,7 +193,7 @@ func (m *migration) scenario(e *sysmlv1.Element, self string) (*scenario, string m: m, e: e, context: context, self: self, order: map[*sysmlv1.Element]int{}, lines: map[*sysmlv1.Element]*lifelineRef{}, - used: map[string]bool{}, + used: inheritedOccurrenceNames(), placed: map[*sysmlv1.Element]bool{}, unwritten: map[*sysmlv1.Element]string{}, uses: map[*sysmlv1.Element]*scenarioStep{}, @@ -1278,6 +1300,9 @@ func (s *scenario) writeSequence() { ref := s.lines[line] s.writeLifeline(ref) s.m.names[line] = ref.name + if ref.rep.Parent == s.e { + s.m.names[ref.rep] = ref.name + } note := "written as the " + strings.TrimSpace(ref.prefix+ref.kw) + " usage " + ref.name + " holding its " + plural(len(ref.events), "event occurrence") switch { case ref.bound != "": diff --git a/internal/translate/migrate/interaction_names_internal_test.go b/internal/translate/migrate/interaction_names_internal_test.go new file mode 100644 index 0000000000..6dde1c1ff0 --- /dev/null +++ b/internal/translate/migrate/interaction_names_internal_test.go @@ -0,0 +1,27 @@ +package migrate + +import "testing" + +func TestInheritedOccurrenceNames(t *testing.T) { + names := inheritedOccurrenceNames() + for _, name := range []string{"self", "this", "portionOfLife", "suboccurrences", "spaceTimeEnclosedOccurrences"} { + if !names[name] { + t.Errorf("missing inherited occurrence member %q", name) + } + } + if names["that"] || names["earlierOccurrence"] || names["isEmpty"] { + t.Error("nested members and private imports are not inherited by Occurrence") + } + if got := freshIn(names, "self"); got != "self2" { + t.Errorf("fresh self = %q, want self2", got) + } + if got := freshIn(names, "self"); got != "self3" { + t.Errorf("second fresh self = %q, want self3", got) + } + if got := freshIn(names, ""); got != "" { + t.Errorf("anonymous name = %q, want empty", got) + } + if !inheritedOccurrenceNames()["self"] || inheritedOccurrenceNames()["self2"] { + t.Error("callers must receive independent name sets") + } +} diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index 9f32c615cc..752f523b48 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -679,6 +679,8 @@ func (m *migration) add(e *sysmlv1.Element, v Verdict, target, note string) { } if src, clash := m.libraryClash[e]; clash { renamed = writtenAs + n + " since a root package named " + src + " would be hidden by the standard library's " + src + } else if e.Parent != nil && e.Parent.Type == "Interaction" && inheritedOccurrenceNames()[e.Name] { + renamed = writtenAs + n + " since an occurrence inherits a member named " + e.Name } else { renamed = writtenAs + n + " since a sibling is also named " + e.Name } diff --git a/tests/migrate/sequence_names_test.go b/tests/migrate/sequence_names_test.go new file mode 100644 index 0000000000..54c900e2e7 --- /dev/null +++ b/tests/migrate/sequence_names_test.go @@ -0,0 +1,70 @@ +package migrate_test + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/translate/migrate" + "github.com/Open-MBEE/OpenSysML/internal/translate/mtip" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestSequenceInheritedOccurrenceName(t *testing.T) { + r := migrateDocument(t, ` + + + + + + + + + + +`, ``) + wantLine(t, r.Notation, "ref part self2 : Block {") + wantLine(t, r.Notation, "message 'send' from self2.sendSent to other.sendReceived;") + if strings.Contains(string(r.Notation), "ref part self :") { + t.Error("sequence declares the inherited name self") + } + ws := model.NewWorkspace() + ws.Open("sequence.sysml", r.Notation, 1) + for _, d := range ws.Diagnostics("sequence.sysml") { + if d.Severity == diag.SeverityError || d.Code == "name-conflict" { + t.Errorf("%v", d) + } + } +} + +func TestSequenceInheritedNameLayout(t *testing.T) { + data, err := os.ReadFile("testdata/xmi/usecase_sequence.xmi") + if err != nil { + t.Fatal(err) + } + data = bytes.ReplaceAll(data, []byte(`name="stage"`), []byte(`name="self"`)) + data = bytes.Replace(data, []byte(`xmi:id="_lStage" name="self"`), []byte(`xmi:id="_lStage"`), 1) + for _, id := range []string{"_lStage", "_pStage"} { + t.Run(id, func(t *testing.T) { + layout := &mtip.Export{Diagrams: []mtip.Diagram{{ID: "_diag_seq", Placements: []mtip.Placement{ + {ID: id, X: 200, Y: 20, Width: 120, Height: 50}, + }}}} + r, err := migrate.MigrateOptions("sequence.xmi", data, migrate.Options{Layout: layout}) + if err != nil { + t.Fatal(err) + } + wantLine(t, r.Notation, "ref part self2 : 'Sample Stage' {") + wantLine(t, r.Notation, "message move from operator.moveSent to self2.moveReceived;") + if id == "_lStage" { + wantLine(t, r.Notation, "metadata DiagramLayout::Layout about self2 { x = 200; y = 20; width = 120; height = 50; }") + } else if r.Report.Layout == nil || r.Report.Layout.PlacementsUnexposed != 1 { + t.Errorf("represented property has no separate sequence symbol: %+v", r.Report.Layout) + } + if strings.Contains(string(r.Notation), "Layout about self {") { + t.Error("layout refers to the inherited self instead of the lifeline") + } + }) + } +} From e2be9c047dc4d359cf7cf46721cbf70d228eeacc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:52:43 +0000 Subject: [PATCH 2/8] perf(migrate): cache inherited occurrence names Co-Authored-By: jason.han --- internal/translate/migrate/interaction.go | 9 ++++++++- .../translate/migrate/interaction_names_internal_test.go | 3 +++ internal/translate/migrate/migrate.go | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index 0240ee5f0a..2a65029378 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -1,10 +1,12 @@ package migrate import ( + "maps" "slices" "sort" "strconv" "strings" + "sync" "github.com/Open-MBEE/OpenSysML/internal/translate/xmi/sysmlv1" "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" @@ -16,7 +18,7 @@ const ( refersToWord = "refers to " ) -func inheritedOccurrenceNames() map[string]bool { +var occurrenceNames = sync.OnceValue(func() map[string]bool { idx := libs.SharedBase() names, seen := map[string]bool{}, map[string]bool{} queue := []string{"Occurrences::Occurrence"} @@ -35,6 +37,11 @@ func inheritedOccurrenceNames() map[string]bool { queue = append(queue, librarySupers(idx, t)...) } return names +}) + +// inheritedOccurrenceNames returns a mutable copy of the cached inherited occurrence names. +func inheritedOccurrenceNames() map[string]bool { + return maps.Clone(occurrenceNames()) } // scenario is an interaction resolved to the steps its v2 form performs in diff --git a/internal/translate/migrate/interaction_names_internal_test.go b/internal/translate/migrate/interaction_names_internal_test.go index 6dde1c1ff0..224ec769e4 100644 --- a/internal/translate/migrate/interaction_names_internal_test.go +++ b/internal/translate/migrate/interaction_names_internal_test.go @@ -24,4 +24,7 @@ func TestInheritedOccurrenceNames(t *testing.T) { if !inheritedOccurrenceNames()["self"] || inheritedOccurrenceNames()["self2"] { t.Error("callers must receive independent name sets") } + if !occurrenceNames()["self"] || occurrenceNames()["self2"] { + t.Error("fresh names must not mutate the shared set") + } } diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index 752f523b48..f5bcae62d0 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -679,7 +679,7 @@ func (m *migration) add(e *sysmlv1.Element, v Verdict, target, note string) { } if src, clash := m.libraryClash[e]; clash { renamed = writtenAs + n + " since a root package named " + src + " would be hidden by the standard library's " + src - } else if e.Parent != nil && e.Parent.Type == "Interaction" && inheritedOccurrenceNames()[e.Name] { + } else if e.Parent != nil && e.Parent.Type == "Interaction" && occurrenceNames()[e.Name] { renamed = writtenAs + n + " since an occurrence inherits a member named " + e.Name } else { renamed = writtenAs + n + " since a sibling is also named " + e.Name From 380080cd626291467f441a84c50723b389abe1ad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:31:13 +0000 Subject: [PATCH 3/8] fix(migrate): resolve expressions through renamed sequence members Co-Authored-By: jason.han --- internal/translate/migrate/behavior.go | 2 + internal/translate/migrate/interaction.go | 43 +++++++++++++++- internal/translate/migrate/values.go | 62 +++++++++++++++++++++-- tests/migrate/sequence_names_test.go | 61 ++++++++++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) diff --git a/internal/translate/migrate/behavior.go b/internal/translate/migrate/behavior.go index 12031d3894..b7991eff21 100644 --- a/internal/translate/migrate/behavior.go +++ b/internal/translate/migrate/behavior.go @@ -571,6 +571,8 @@ func (m *migration) v2Spelled(text string, refs []reference, scope *sysmlv1.Elem visible, _ := m.visibleFrom(scope) text = m.renamedRoots(text, refs, visible, nil) refs, _ = exprRefs(text) + text = m.renamedSequenceMembers(text, refs, scope) + refs, _ = exprRefs(text) return m.qualifySelf(text, refs, scope) } diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index 2a65029378..ce691c7c44 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -351,6 +351,7 @@ func (s *scenario) use(f *sysmlv1.Element, body *[]*scenarioStep) (*scenarioStep base = n } step.base = freshIn(s.used, base) + s.m.names[f] = step.base step.name = writeName(step.base) s.returnValue(step) s.uses[f] = step @@ -575,6 +576,10 @@ func (s *scenario) lifeline(line *sysmlv1.Element) (*lifelineRef, string) { return nil, standsFor + describe(rep) + ", a " + rep.Type + " rather than a part or parameter" } ref.name = freshIn(s.used, name) + s.m.names[line] = ref.name + if rep.Parent == s.e { + s.m.names[rep] = ref.name + } ref.from = writeName(ref.name) s.lines[line] = ref return ref, "" @@ -1023,6 +1028,17 @@ func (s *scenario) inaccessible(expr string, line *lifelineRef) string { e, _ = s.m.memberNamed(e, r.steps[n].name, chainKind(false)) n++ } + if e != nil && e.Parent == s.e { + if _, ok := s.m.sequenceParams[e]; ok { + continue + } + for _, ref := range s.lines { + if ref.rep == e { + e = nil + break + } + } + } if e != nil && isFeature(e) { return r.text(n) } @@ -1050,6 +1066,22 @@ func (s *scenario) parameterNamed(name string) bool { return false } +// sequenceFeature reports whether an interaction feature has a scenario declaration. +func (m *migration) sequenceFeature(e *sysmlv1.Element) bool { + if e.Parent == nil || e.Parent.Type != "Interaction" { + return false + } + if _, ok := m.sequenceParams[e]; ok { + return true + } + for _, line := range e.Parent.Owned("lifeline") { + if _, named := m.names[line]; named && m.model.Ref(line, "represents") == e { + return true + } + } + return false +} + // lifelineNamed finds the lifeline usage of the given name. func (s *scenario) lifelineNamed(name string) *lifelineRef { for _, ref := range s.lines { @@ -1067,6 +1099,7 @@ func (s *scenario) stepName(msg *sysmlv1.Element, fallback string) (base, writte name = lowerFirst(fallback) } base = freshIn(s.used, name) + s.m.names[msg] = base return base, writeName(base) } @@ -1576,9 +1609,10 @@ func sequenceParameters(e *sysmlv1.Element) []*sysmlv1.Element { return in } -// declareParameters names the attributes the interaction's in parameters are written -// as, before the lifelines take their names; a parameter a lifeline stands for is that usage. +// declareParameters allocates all input names before writing defaults that may read later parameters. +// A parameter represented by a lifeline is declared by that usage instead. func (s *scenario) declareParameters() { + var parameters []*sysmlv1.Element for _, p := range sequenceParameters(s.e) { represented := false for _, ref := range s.lines { @@ -1596,6 +1630,11 @@ func (s *scenario) declareParameters() { } name := freshIn(s.used, s.m.nameOf(p)) s.m.sequenceParams[p] = name + s.m.names[p] = name + parameters = append(parameters, p) + } + for _, p := range parameters { + name := s.m.sequenceParams[p] line := "attribute " + writeName(name) if mult, _ := s.m.multiplicity(p); mult != "" { line += mult diff --git a/internal/translate/migrate/values.go b/internal/translate/migrate/values.go index bf742a0b6e..11ba8f7722 100644 --- a/internal/translate/migrate/values.go +++ b/internal/translate/migrate/values.go @@ -195,6 +195,8 @@ func (m *migration) opaqueValue(v, scope *sysmlv1.Element, want wanted) (expr st } visible, _ := m.visibleFrom(scope) body = m.renamedRoots(body, refs, visible, nil) + refs, _ = exprRefs(body) + body = m.renamedSequenceMembers(body, refs, scope) if m.selfContext(scope) != nil { // Inside a def the classifier's features read through its context parameter. refs, _ = exprRefs(body) @@ -406,6 +408,8 @@ type reference struct { type step struct { name string chain bool + start int + len int } // text writes the reference's first n steps as the expression spells them. @@ -453,6 +457,9 @@ func exprRefs(text string) (refs []reference, ok bool) { } for i := range c.refs { c.refs[i].start -= len(exprProbePrefix) + for j := range c.refs[i].steps { + c.refs[i].steps[j].start -= len(exprProbePrefix) + } } return c.refs, true } @@ -775,7 +782,7 @@ func qualifiedRef(q *ast.QualifiedName) (reference, bool) { } r := reference{global: q.Global, start: q.Parts[0].Span.Offset, firstLen: q.Parts[0].Span.Len} for _, p := range q.Parts { - r.steps = append(r.steps, step{name: p.Text}) + r.steps = append(r.steps, step{name: p.Text, start: p.Span.Offset, len: p.Span.Len}) } return r, true } @@ -797,7 +804,7 @@ func chainRef(e *ast.FeatureChainExpr) (reference, bool) { return reference{}, false } for i, p := range e.Member.Parts { - r.steps = append(r.steps, step{name: p.Text, chain: i == 0}) + r.steps = append(r.steps, step{name: p.Text, chain: i == 0, start: p.Span.Offset, len: p.Span.Len}) } return r, true } @@ -872,6 +879,41 @@ func (m *migration) viaRenamedRoot(r reference, visible, hidden map[string]*sysm return ce, "" } +// renamedSequenceMembers spells each resolved interaction member by its allocated scenario name. +func (m *migration) renamedSequenceMembers(text string, refs []reference, scope *sysmlv1.Element) string { + visible, _ := m.visibleFrom(scope) + type replacement struct { + start, end int + name string + } + byStart := map[int]replacement{} + for _, r := range refs { + if r.local != "" { + continue + } + for i, part := range r.steps { + prefix := r + prefix.steps = r.steps[:i+1] + e, _, missing := m.resolve(prefix, visible, nil) + if missing != "" || e == nil || e.Parent == nil || e.Parent.Type != "Interaction" || part.len == 0 { + continue + } + if name := m.nameOf(e); name != part.name { + byStart[part.start] = replacement{part.start, part.start + part.len, writeName(name)} + } + } + } + var replacements []replacement + for _, rp := range byStart { + replacements = append(replacements, rp) + } + sort.Slice(replacements, func(i, j int) bool { return replacements[i].start > replacements[j].start }) + for _, rp := range replacements { + text = text[:rp.start] + rp.name + text[rp.end:] + } + return text +} + // renamedRoots rewrites, in text, each reference whose first step spells the // source name of a root avoidLibraryRoots renamed, to the name it is written // as. A nearer element answering to the name keeps the spelling, and so does @@ -1068,7 +1110,7 @@ func (m *migration) membersOf(e *sysmlv1.Element, kind memberKind) (visible, hid seen[t] = true for _, c := range t.Children { n := m.nameOf(c) - if n == "" || !m.written(c) || (features && c.Type != "Property" && c.Type != "Port") { + if n == "" || (!m.sequenceFeature(c) && !m.written(c)) || (features && c.Type != "Property" && c.Type != "Port") { continue } if m.hiddenFromHeirs(c) { @@ -1081,6 +1123,15 @@ func (m *migration) membersOf(e *sysmlv1.Element, kind memberKind) (visible, hid visible[n] = c } } + for _, c := range t.Children { + if t.Type == "Interaction" && c.Name != "" && (m.sequenceFeature(c) || m.written(c)) && (!features || c.Type == "Property" || c.Type == "Port") { + if m.hiddenFromHeirs(c) { + hidden[c.Name] = c + } else { + visible[c.Name] = c + } + } + } for _, g := range t.Owned("generalization") { walk(m.model.Ref(g, "general")) } @@ -1151,7 +1202,10 @@ func (v *visibility) members(e *sysmlv1.Element, inherited bool) { func (v *visibility) member(c *sysmlv1.Element, inherited bool) { n := v.m.nameOf(c) - if n == "" || !v.m.written(c) { + if c.Parent != nil && c.Parent.Type == "Interaction" && c.Name != "" { + n = c.Name + } + if n == "" || (!v.m.sequenceFeature(c) && !v.m.written(c)) { return } if inherited && v.m.hiddenFromHeirs(c) { diff --git a/tests/migrate/sequence_names_test.go b/tests/migrate/sequence_names_test.go index 54c900e2e7..e4e45980da 100644 --- a/tests/migrate/sequence_names_test.go +++ b/tests/migrate/sequence_names_test.go @@ -68,3 +68,64 @@ func TestSequenceInheritedNameLayout(t *testing.T) { }) } } + +func TestSequenceRenamedMemberExpressions(t *testing.T) { + for _, lang := range []string{"", "OCL"} { + t.Run(lang, func(t *testing.T) { + r := migrateDocument(t, ` + + + + + + + + `+lang+`portionOfLife + 1 + + + + + + + + 'self'.amount + + + + Sequence::portionOfLife + + + + +`, ``) + wantLine(t, r.Notation, "attribute copy : ScalarValues::Real default = portionOfLife2 + 1;") + wantLine(t, r.Notation, "attribute portionOfLife2 : ScalarValues::Real default = 2.0;") + wantLine(t, r.Notation, "attribute amountCopy : ScalarValues::Real default = self2.amount;") + wantLine(t, r.Notation, "attribute qualified : ScalarValues::Real default = Sequence::portionOfLife2;") + if strings.Contains(string(r.Notation), "default value not migrated") { + t.Errorf("a reachable default was lost:\n%s", r.Notation) + } + for _, d := range errors(t, "renamed-sequence.sysml", r.Notation) { + t.Errorf("%v", d) + } + }) + } +} + +func TestSequenceRenamedParameterMessageArgument(t *testing.T) { + data, err := os.ReadFile("testdata/xmi/interaction_use.xmi") + if err != nil { + t.Fatal(err) + } + data = bytes.Replace(data, []byte(`xmi:id="_suRpm" name="rpm"`), []byte(`xmi:id="_suRpm" name="portionOfLife"`), 1) + data = bytes.Replace(data, []byte(`rpm`), []byte(`portionOfLife`), 1) + r, err := migrate.Migrate("interaction_use.xmi", data) + if err != nil { + t.Fatal(err) + } + wantLine(t, r.Notation, "perform action spinCall ::> Motor::spin { in rpm[1] = portionOfLife2; }") + wantLine(t, r.Notation, "occurrence warm : 'Spin Up' { attribute :>> portionOfLife2 = 30.0; }") + for _, d := range errors(t, "renamed-argument.sysml", r.Notation) { + t.Errorf("%v", d) + } +} From 88e9df42f537b3148eba6ed497b0f144e8ad9cb1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:31:31 +0000 Subject: [PATCH 4/8] fix(migrate): key occurrence names by the active library index Co-Authored-By: jason.han --- internal/translate/migrate/interaction.go | 42 ++++++++++++-------- tests/migrate/sequence_library_names_test.go | 42 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 tests/migrate/sequence_library_names_test.go diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index ce691c7c44..9752950bcb 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -18,26 +18,34 @@ const ( refersToWord = "refers to " ) -var occurrenceNames = sync.OnceValue(func() map[string]bool { +var occurrenceNameCache sync.Map + +func occurrenceNames() map[string]bool { idx := libs.SharedBase() - names, seen := map[string]bool{}, map[string]bool{} - queue := []string{"Occurrences::Occurrence"} - for len(queue) > 0 { - t := queue[0] - queue = queue[1:] - if seen[t] { - continue - } - seen[t] = true - for _, member := range idx.LookupDirectChildrenFrom(t, "") { - if member.Name != "" { - names[member.Name] = true + cached, ok := occurrenceNameCache.Load(idx) + if !ok { + cached, _ = occurrenceNameCache.LoadOrStore(idx, sync.OnceValue(func() map[string]bool { + names, seen := map[string]bool{}, map[string]bool{} + queue := []string{"Occurrences::Occurrence"} + for len(queue) > 0 { + t := queue[0] + queue = queue[1:] + if seen[t] { + continue + } + seen[t] = true + for _, member := range idx.LookupDirectChildrenFrom(t, "") { + if member.Name != "" { + names[member.Name] = true + } + } + queue = append(queue, librarySupers(idx, t)...) } - } - queue = append(queue, librarySupers(idx, t)...) + return names + })) } - return names -}) + return cached.(func() map[string]bool)() +} // inheritedOccurrenceNames returns a mutable copy of the cached inherited occurrence names. func inheritedOccurrenceNames() map[string]bool { diff --git a/tests/migrate/sequence_library_names_test.go b/tests/migrate/sequence_library_names_test.go new file mode 100644 index 0000000000..dcd67ac169 --- /dev/null +++ b/tests/migrate/sequence_library_names_test.go @@ -0,0 +1,42 @@ +package migrate_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +func TestSequenceNamesFollowLibraryOverride(t *testing.T) { + const source = ` + + + + + +` + const profile = `` + t.Setenv(libs.LibraryPathEnvVar, "") + migrateDocument(t, source, profile) + previous := libs.SharedBase() + for _, member := range []string{"libraryMember", "otherMember"} { + dir := t.TempDir() + text := "package Occurrences { abstract class Occurrence { feature " + member + "; } }" + if err := os.WriteFile(filepath.Join(dir, "Occurrences.kerml"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(libs.LibraryPathEnvVar, dir) + idx := libs.SharedBase() + if idx == previous || len(idx.LookupQualified("Occurrences::Occurrence::"+member)) != 1 { + t.Fatal("library override did not load a distinct index with its declared member") + } + previous = idx + r := migrateDocument(t, source, profile) + want := "ref part libraryMember : Block;" + if member == "libraryMember" { + want = "ref part libraryMember2 : Block;" + } + wantLine(t, r.Notation, want) + } +} From 8c7ed25a617c131a987da99f98272c4850d805a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:45:23 +0000 Subject: [PATCH 5/8] fix(migrate): match named sequence arguments by source names Co-Authored-By: jason.han --- internal/translate/migrate/interaction.go | 2 +- .../migrate/sequence_named_arguments_test.go | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/migrate/sequence_named_arguments_test.go diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index 9752950bcb..d3a190fdcc 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -905,7 +905,7 @@ func (s *scenario) pairArguments(args, targets []*sysmlv1.Element) []*sysmlv1.El continue } for _, t := range targets { - if s.m.nameOf(t) == arg.Name { + if t.Name == arg.Name { paired[i], named[t] = t, true break } diff --git a/tests/migrate/sequence_named_arguments_test.go b/tests/migrate/sequence_named_arguments_test.go new file mode 100644 index 0000000000..eaf6f1f24c --- /dev/null +++ b/tests/migrate/sequence_named_arguments_test.go @@ -0,0 +1,57 @@ +package migrate_test + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/translate/migrate" +) + +func TestSequenceNamedArgumentsUseSourceParameterNames(t *testing.T) { + for _, tc := range []struct { + name, arguments, bindings string + }{ + {"named", ``, + "attribute :>> portionOfLife2 = 30.0;"}, + {"mixed", ` +`, + "attribute :>> portionOfLife22 = 40.0; attribute :>> portionOfLife2 = 30.0;"}, + {"missing", ``, ""}, + {"empty", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + r := migrateDocument(t, ` + + + + + + + + + + + `+tc.arguments+` + +`, ``) + wantLine(t, r.Notation, "attribute portionOfLife2 : ScalarValues::Real;") + wantLine(t, r.Notation, "attribute portionOfLife22 : ScalarValues::Real;") + if tc.bindings != "" { + wantLine(t, r.Notation, "occurrence run : Target { "+tc.bindings+" }") + for _, entry := range r.Report.Entries { + if entry.ID == "_use" && strings.Contains(entry.Note, "has no parameter") { + t.Errorf("a source-named argument was dropped: %s", entry.Note) + } + } + } else { + wantLine(t, r.Notation, "occurrence run : Target;") + if tc.name == "missing" { + wantNote(t, r, "_use", migrate.Approximated, "has no parameter of Target to bind to and is dropped") + } + } + for _, d := range errors(t, "named-arguments.sysml", r.Notation) { + t.Errorf("%v", d) + } + }) + } +} From 373c0d713850525660c1bbeeb5f225350c3ed68d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:51:13 +0000 Subject: [PATCH 6/8] fix(migrate): spell renamed expression paths from source references Co-Authored-By: jason.han --- internal/translate/migrate/behavior.go | 5 +- internal/translate/migrate/values.go | 37 +++----------- .../migrate/sequence_expression_names_test.go | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 35 deletions(-) create mode 100644 tests/migrate/sequence_expression_names_test.go diff --git a/internal/translate/migrate/behavior.go b/internal/translate/migrate/behavior.go index b7991eff21..05c2c917b2 100644 --- a/internal/translate/migrate/behavior.go +++ b/internal/translate/migrate/behavior.go @@ -568,10 +568,7 @@ func (m *migration) v2Refs(text, lang string, scope *sysmlv1.Element) (refs []re // v2Spelled writes text, whose names are refs, as read inside scope: its // library roots renamed and its owner's features qualified. func (m *migration) v2Spelled(text string, refs []reference, scope *sysmlv1.Element) string { - visible, _ := m.visibleFrom(scope) - text = m.renamedRoots(text, refs, visible, nil) - refs, _ = exprRefs(text) - text = m.renamedSequenceMembers(text, refs, scope) + text = m.renamedReferences(text, refs, scope) refs, _ = exprRefs(text) return m.qualifySelf(text, refs, scope) } diff --git a/internal/translate/migrate/values.go b/internal/translate/migrate/values.go index 11ba8f7722..55b511904a 100644 --- a/internal/translate/migrate/values.go +++ b/internal/translate/migrate/values.go @@ -193,10 +193,7 @@ func (m *migration) opaqueValue(v, scope *sysmlv1.Element, want wanted) (expr st if problem := m.invisible(refs, scope); problem != "" { return "", false, "opaque expression " + problem + langNote(lang) } - visible, _ := m.visibleFrom(scope) - body = m.renamedRoots(body, refs, visible, nil) - refs, _ = exprRefs(body) - body = m.renamedSequenceMembers(body, refs, scope) + body = m.renamedReferences(body, refs, scope) if m.selfContext(scope) != nil { // Inside a def the classifier's features read through its context parameter. refs, _ = exprRefs(body) @@ -879,8 +876,8 @@ func (m *migration) viaRenamedRoot(r reference, visible, hidden map[string]*sysm return ce, "" } -// renamedSequenceMembers spells each resolved interaction member by its allocated scenario name. -func (m *migration) renamedSequenceMembers(text string, refs []reference, scope *sysmlv1.Element) string { +// renamedReferences spells renamed roots and interaction members from their source references. +func (m *migration) renamedReferences(text string, refs []reference, scope *sysmlv1.Element) string { visible, _ := m.visibleFrom(scope) type replacement struct { start, end int @@ -891,6 +888,9 @@ func (m *migration) renamedSequenceMembers(text string, refs []reference, scope if r.local != "" { continue } + if root, _ := m.viaRenamedRoot(r, visible, nil); root != nil && r.firstLen != 0 { + byStart[r.start] = replacement{r.start, r.start + r.firstLen, writeName(m.nameOf(root))} + } for i, part := range r.steps { prefix := r prefix.steps = r.steps[:i+1] @@ -914,31 +914,6 @@ func (m *migration) renamedSequenceMembers(text string, refs []reference, scope return text } -// renamedRoots rewrites, in text, each reference whose first step spells the -// source name of a root avoidLibraryRoots renamed, to the name it is written -// as. A nearer element answering to the name keeps the spelling, and so does -// a reference that resolves only through the library package the root hides. -func (m *migration) renamedRoots(text string, refs []reference, visible, hidden map[string]*sysmlv1.Element) string { - type replacement struct { - start, end int - name string - } - var reps []replacement - for _, r := range refs { - if r.local != "" || len(r.steps) == 0 || r.steps[0].chain || r.firstLen == 0 { - continue - } - if ce, _ := m.viaRenamedRoot(r, visible, hidden); ce != nil { - reps = append(reps, replacement{r.start, r.start + r.firstLen, writeName(m.names[ce])}) - } - } - sort.Slice(reps, func(i, j int) bool { return reps[i].start > reps[j].start }) - for _, rp := range reps { - text = text[:rp.start] + rp.name + text[rp.end:] - } - return text -} - // notAValue names the kind of declaration e becomes when an expression cannot // read it: an operation or behavior written as an action or state def. func (m *migration) notAValue(e *sysmlv1.Element) string { diff --git a/tests/migrate/sequence_expression_names_test.go b/tests/migrate/sequence_expression_names_test.go new file mode 100644 index 0000000000..2464741b66 --- /dev/null +++ b/tests/migrate/sequence_expression_names_test.go @@ -0,0 +1,49 @@ +package migrate_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/translate/migrate" +) + +func sequenceExpressionDocument(body string) []byte { + return document(` + + `+realHref+` + + `+realHref+` + `+body+` + + `+realHref+` + + + +`, ``) +} + +func checkSequenceExpression(t *testing.T, data []byte, want string) { + t.Helper() + r, err := migrate.Migrate("sequence-expression.xmi", data) + if err != nil { + t.Fatal(err) + } + wantLine(t, r.Notation, "attribute copy : ScalarValues::Real default = "+want+";") + if strings.Contains(string(r.Notation), "default value not migrated") { + t.Errorf("reachable expression was lost:\n%s", r.Notation) + } + for _, d := range errors(t, "sequence-expression.sysml", r.Notation) { + t.Errorf("%v", d) + } +} + +func TestSequenceExpressionThroughRenamedRoot(t *testing.T) { + for _, prefix := range []string{"", "$::"} { + t.Run(prefix, func(t *testing.T) { + data := sequenceExpressionDocument(prefix + "Occurrences::Block::Sequence::portionOfLife + " + prefix + "Occurrences::Block::Sequence::portionOfLife") + data = bytes.Replace(data, []byte(`name="Model"`), []byte(`name="Occurrences"`), 1) + checkSequenceExpression(t, data, prefix+"OccurrencesModel::Block::Sequence::portionOfLife2 + "+prefix+"OccurrencesModel::Block::Sequence::portionOfLife2") + }) + } +} From 145eec5cf5108ab963db7f71f4327584818defcf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:52:05 +0000 Subject: [PATCH 7/8] fix(migrate): rewrite sequence members through typed expression locals Co-Authored-By: jason.han --- internal/translate/migrate/values.go | 10 +++++++--- tests/migrate/sequence_expression_names_test.go | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/translate/migrate/values.go b/internal/translate/migrate/values.go index 55b511904a..584e9b48b7 100644 --- a/internal/translate/migrate/values.go +++ b/internal/translate/migrate/values.go @@ -843,7 +843,7 @@ func (m *migration) invisible(refs []reference, scope *sysmlv1.Element) string { // fails when a source child of the failing element claims the name but is // itself unwritten — the path is then the user's, not the library's. func (m *migration) viaRenamedRoot(r reference, visible, hidden map[string]*sysmlv1.Element) (root *sysmlv1.Element, missing string) { - if r.local != "" || len(r.steps) == 0 || r.steps[0].chain { + if len(r.steps) == 0 || r.steps[0].chain { return nil, "" } name := r.steps[0].name @@ -859,7 +859,11 @@ func (m *migration) viaRenamedRoot(r reference, visible, hidden map[string]*sysm } e := ce for i, s := range r.steps[1:] { - next, private := m.memberNamed(e, s.name, chainKind(s.chain)) + kind := chainKind(s.chain) + if r.local != "" && i+1 == r.typed { + kind = memberFeature + } + next, private := m.memberNamed(e, s.name, kind) if next == nil && private != nil && hidden != nil { next = private } @@ -885,7 +889,7 @@ func (m *migration) renamedReferences(text string, refs []reference, scope *sysm } byStart := map[int]replacement{} for _, r := range refs { - if r.local != "" { + if r.local != "" && r.typed == 0 { continue } if root, _ := m.viaRenamedRoot(r, visible, nil); root != nil && r.firstLen != 0 { diff --git a/tests/migrate/sequence_expression_names_test.go b/tests/migrate/sequence_expression_names_test.go index 2464741b66..c7bd363e0d 100644 --- a/tests/migrate/sequence_expression_names_test.go +++ b/tests/migrate/sequence_expression_names_test.go @@ -47,3 +47,20 @@ func TestSequenceExpressionThroughRenamedRoot(t *testing.T) { }) } } + +func TestSequenceExpressionTypedLocalMember(t *testing.T) { + for _, root := range []string{"Model", "Occurrences"} { + for _, local := range []string{"x", "'self'"} { + t.Run(root+"/"+local, func(t *testing.T) { + typ := root + "::Block::Sequence" + body := typ + "->ControlFunctions::collect {in " + local + " : " + typ + "; " + local + ".'self'.amount}" + data := sequenceExpressionDocument(body) + data = bytes.Replace(data, []byte(`name="Model"`), []byte(`name="`+root+`"`), 1) + if root == "Occurrences" { + typ = "OccurrencesModel::Block::Sequence" + } + checkSequenceExpression(t, data, typ+"->ControlFunctions::collect {in "+local+" : "+typ+"; "+local+".self2.amount}") + }) + } + } +} From 041116cc0ce4efc3dce0e7a5e0cc43aa253b95ed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:52:39 +0000 Subject: [PATCH 8/8] test(migrate): use visible types in local expression regression Co-Authored-By: jason.han --- tests/migrate/sequence_expression_names_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/migrate/sequence_expression_names_test.go b/tests/migrate/sequence_expression_names_test.go index c7bd363e0d..39d7d66c15 100644 --- a/tests/migrate/sequence_expression_names_test.go +++ b/tests/migrate/sequence_expression_names_test.go @@ -52,7 +52,10 @@ func TestSequenceExpressionTypedLocalMember(t *testing.T) { for _, root := range []string{"Model", "Occurrences"} { for _, local := range []string{"x", "'self'"} { t.Run(root+"/"+local, func(t *testing.T) { - typ := root + "::Block::Sequence" + typ := "Sequence" + if root == "Occurrences" { + typ = root + "::Block::Sequence" + } body := typ + "->ControlFunctions::collect {in " + local + " : " + typ + "; " + local + ".'self'.amount}" data := sequenceExpressionDocument(body) data = bytes.Replace(data, []byte(`name="Model"`), []byte(`name="`+root+`"`), 1)