From 1cc69b26de28ff2520b260ad69cc65adab9da45b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:29:26 +0000 Subject: [PATCH 1/5] feat(ir): add program package with graph-local ids, Origin and GraphID Declare the execution-owned identity every lowered record will carry: the per-graph dense id types, NodeKind, Origin (document, span, path, kind word, name) with the String rendering today's messages use, and GraphID as a type whose encoder is not written yet. The package reads no syntax; the layering test registers it in the ir layer and forbids program -> syntax/ast. Co-Authored-By: jason.han --- docs/internals/architecture.md | 1 + internal/ir/program/kind.go | 71 +++++++++++++++++++++++++++++ internal/ir/program/origin.go | 35 ++++++++++++++ internal/ir/program/program.go | 46 +++++++++++++++++++ internal/ir/program/program_test.go | 69 ++++++++++++++++++++++++++++ tests/hygiene/layering_test.go | 2 + 6 files changed, 224 insertions(+) create mode 100644 internal/ir/program/kind.go create mode 100644 internal/ir/program/origin.go create mode 100644 internal/ir/program/program.go create mode 100644 internal/ir/program/program_test.go diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 48452b1a05..0dccad0f3e 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -431,6 +431,7 @@ Parse + model all behavioral bodies with unified fallback grammar: - `trace.go` (154 lines) — Deterministic execution trace recorder - `eval.go` — Expression evaluation (binary/unary operators, literals, feature references, qualified names, type coercion) - Lowering to execution IR lives in `internal/ir/lower/` (`ToActionGraph`, `ToStateGraph`) +- Graph-local record identity (`NodeID`, `StateID`, …, `Origin`, `GraphID`) is declared in `internal/ir/program/`, which reads no syntax; `lower` assigns it **Testing:** - **Golden ASTs**: `tests/parser/testdata/parse/` — count in [the measured counts](../project/spec-compliance.md) diff --git a/internal/ir/program/kind.go b/internal/ir/program/kind.go new file mode 100644 index 0000000000..ca5bcef305 --- /dev/null +++ b/internal/ir/program/kind.go @@ -0,0 +1,71 @@ +package program + +import "strconv" + +// NodeKind is what a node of an action graph does when a token reaches it. +type NodeKind uint8 + +const ( + // NodeNone is the zero kind, carried by no node. + NodeNone NodeKind = iota + // NodeInitial is the flow's entry, `first start`. + NodeInitial + // NodeFinal is a node the flow ends at, `done`. + NodeFinal + // NodeFork splits a token across every outgoing succession. + NodeFork + // NodeJoin waits for a token on every incoming succession. + NodeJoin + // NodeMerge passes a token on from any incoming succession. + NodeMerge + // NodeDecision chooses one guarded outgoing succession. + NodeDecision + // NodeExecution runs a nested action, `action a { ... }`. + NodeExecution + // NodePerform performs a referenced action, `perform action a`. + NodePerform + // NodeAccept waits for an event, `accept e`. + NodeAccept + // NodeSend dispatches an event, `send e to t`. + NodeSend + // NodeAssign writes a feature, `assign x := e`. + NodeAssign + // NodeTerminate ends the enclosing behavior, `terminate`. + NodeTerminate + // NodeIf branches on a condition, `if c { ... }`. + NodeIf + // NodeLoop repeats a body, `while c { ... }` and `for i in s { ... }`. + NodeLoop + // NodeBlock runs a body-local block of nodes. + NodeBlock + // NodeStatement runs the statements written between two nodes of a block as one step. + NodeStatement +) + +var nodeKindNames = [...]string{ + NodeNone: "none", + NodeInitial: "initial", + NodeFinal: "final", + NodeFork: "fork", + NodeJoin: "join", + NodeMerge: "merge", + NodeDecision: "decision", + NodeExecution: "action", + NodePerform: "perform", + NodeAccept: "accept", + NodeSend: "send", + NodeAssign: "assign", + NodeTerminate: "terminate", + NodeIf: "if", + NodeLoop: "loop", + NodeBlock: "block", + NodeStatement: "statement", +} + +// String is the kind's word in the notation. +func (k NodeKind) String() string { + if int(k) < len(nodeKindNames) { + return nodeKindNames[k] + } + return "NodeKind(" + strconv.Itoa(int(k)) + ")" +} diff --git a/internal/ir/program/origin.go b/internal/ir/program/origin.go new file mode 100644 index 0000000000..940e26100f --- /dev/null +++ b/internal/ir/program/origin.go @@ -0,0 +1,35 @@ +package program + +import "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + +// Origin is where a lowered record came from: what a message, a trace or a hover +// needs to say about it, carried as plain data so that the record outlives the +// syntax tree it was lowered from. +type Origin struct { + // Doc is the name of the document the declaration was written in, "" outside any. + Doc string + // Span is the declaration's extent in Doc. + Span source.Span + // Path is the declaration's path from the root, unique within its graph: + // its qualified name when it has one, else its owner's with the position + // among the owner's unnamed members of its kind (`A::b`, `A#if[2]`). + Path string + // Element is the declaration's element id when one is known, else "". + Element string + // Kind is the word today's messages name the declaration by when it is unnamed: + // an action node's declaring type as %T renders it, a vertex's `state` or `pseudostate`. + Kind string + // Name is the declaration's name as messages use it, "" when it has none. + Name string + // Docs is the declaration's documentation prose, "" when it has none. + Docs string +} + +// String names the record the way a message does: by name when it has one, +// else by kind. +func (o Origin) String() string { + if o.Name != "" { + return "node " + o.Name + } + return "the " + o.Kind +} diff --git a/internal/ir/program/program.go b/internal/ir/program/program.go new file mode 100644 index 0000000000..9ba632ae16 --- /dev/null +++ b/internal/ir/program/program.go @@ -0,0 +1,46 @@ +// Package program is the execution-owned intermediate representation: the +// identity every lowered record carries, independent of the syntax tree it +// was lowered from. Ids are indices into the owning graph's tables, dense and +// assigned in declaration order, so two lowerings of one model agree on them; +// zero is "none". A graph's ids are local to it, and a GraphID tells graphs apart. +// +// The package reads no syntax: lower is the one package that reads the tree and +// writes these records, and executors consume them. +package program + +import "encoding/hex" + +// NodeID indexes a node of an action graph. +type NodeID uint32 + +// EdgeID indexes a succession of an action graph. +type EdgeID uint32 + +// StmtID indexes a statement of an action graph. +type StmtID uint32 + +// ExprID indexes an expression, shared by the action, state machine and calculation graphs. +type ExprID uint32 + +// StateID indexes a state of a state machine graph. +type StateID uint32 + +// RegionID indexes a region of a state machine graph. +type RegionID uint32 + +// PseudoID indexes a pseudostate of a state machine graph. +type PseudoID uint32 + +// TransID indexes a transition of a state machine graph. +type TransID uint32 + +// GraphID identifies a lowered graph by the digest of its canonical encoding, so +// two lowerings of one model yield one id and an edit to the model moves it. The +// encoder that computes it is not written yet; a zero GraphID is "not computed". +type GraphID [16]byte + +// IsZero reports whether the id was never computed. +func (id GraphID) IsZero() bool { return id == GraphID{} } + +// String renders the id as hex. +func (id GraphID) String() string { return hex.EncodeToString(id[:]) } diff --git a/internal/ir/program/program_test.go b/internal/ir/program/program_test.go new file mode 100644 index 0000000000..8b8685da34 --- /dev/null +++ b/internal/ir/program/program_test.go @@ -0,0 +1,69 @@ +package program + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func TestOriginString(t *testing.T) { + cases := []struct { + name string + origin Origin + want string + }{ + {"named", Origin{Name: "step", Kind: "*ast.Usage"}, "node step"}, + {"final", Origin{Name: "done", Kind: "*ast.FinalNode"}, "node done"}, + {"unnamed", Origin{Kind: "*ast.IfActionNode"}, "the *ast.IfActionNode"}, + {"vertex", Origin{Kind: "state"}, "the state"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.origin.String(); got != tc.want { + t.Fatalf("String() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestOriginIsPlainData(t *testing.T) { + a := Origin{Doc: "m.sysml", Span: source.Span{Offset: 3, Len: 4}, Path: "A::b", Name: "b", Kind: "*ast.Usage"} + b := a + if a != b { + t.Fatal("Origin must be comparable and copy by value") + } +} + +func TestNodeKindString(t *testing.T) { + for k := NodeNone; k <= NodeStatement; k++ { + if s := k.String(); s == "" || strings.HasPrefix(s, "NodeKind(") { + t.Errorf("NodeKind %d has no name", k) + } + } + if got := NodeKind(200).String(); got != "NodeKind(200)" { + t.Errorf("unknown kind renders %q", got) + } + if NodeNone != 0 { + t.Error("the zero kind must be none") + } +} + +func TestGraphIDZero(t *testing.T) { + var id GraphID + if !id.IsZero() { + t.Error("the zero GraphID must report IsZero") + } + id[0] = 1 + if id.IsZero() || id.String() != "01000000000000000000000000000000" { + t.Errorf("IsZero()=%v String()=%q", id.IsZero(), id) + } +} + +func TestIDsAreGraphLocalIndices(t *testing.T) { + // Zero is "none" for every id type; the first record of a table is 1. + if NodeID(0) != 0 || EdgeID(0) != 0 || StmtID(0) != 0 || ExprID(0) != 0 || + StateID(0) != 0 || RegionID(0) != 0 || PseudoID(0) != 0 || TransID(0) != 0 { + t.Fatal("zero ids") + } +} diff --git a/tests/hygiene/layering_test.go b/tests/hygiene/layering_test.go index 461fb6f0f6..9cc2724242 100644 --- a/tests/hygiene/layering_test.go +++ b/tests/hygiene/layering_test.go @@ -62,6 +62,7 @@ var packageLayer = map[string]string{ "internal/semantic/metamodel": "semantic", "internal/ir/lower": "ir", + "internal/ir/program": "ir", "internal/ir/queryplan": "ir", "internal/ir/docplan": "ir", "internal/ir/docplan/markup": "ir", @@ -210,6 +211,7 @@ var removed = map[string][]string{ "internal/exec/runtime": {"internal/syntax/parser", "internal/check/passes"}, "internal/semantic/query": {"internal/translate/rdf"}, "internal/frontend/repl": {"internal/frontend/grpc"}, + "internal/ir/program": {"internal/syntax/ast"}, } // TestPackageLayering checks the import graph against the layer tables: every From f9742b58029d731763ed086657c25b9680c27928 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:34:42 +0000 Subject: [PATCH 2/5] style(ir): keep program comments to two lines Co-Authored-By: jason.han --- internal/ir/program/origin.go | 10 ++++------ internal/ir/program/program.go | 15 ++++----------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/internal/ir/program/origin.go b/internal/ir/program/origin.go index 940e26100f..9f6f977202 100644 --- a/internal/ir/program/origin.go +++ b/internal/ir/program/origin.go @@ -2,17 +2,15 @@ package program import "github.com/Open-MBEE/OpenSysML/internal/syntax/source" -// Origin is where a lowered record came from: what a message, a trace or a hover -// needs to say about it, carried as plain data so that the record outlives the -// syntax tree it was lowered from. +// Origin is where a lowered record came from, as plain data: what a message, a +// trace or a hover says about it once the syntax tree is gone. type Origin struct { // Doc is the name of the document the declaration was written in, "" outside any. Doc string // Span is the declaration's extent in Doc. Span source.Span - // Path is the declaration's path from the root, unique within its graph: - // its qualified name when it has one, else its owner's with the position - // among the owner's unnamed members of its kind (`A::b`, `A#if[2]`). + // Path is unique within the graph: the declaration's qualified name, else its + // owner's and its position among the owner's unnamed members of its kind (`A#if[2]`). Path string // Element is the declaration's element id when one is known, else "". Element string diff --git a/internal/ir/program/program.go b/internal/ir/program/program.go index 9ba632ae16..bbaf55a826 100644 --- a/internal/ir/program/program.go +++ b/internal/ir/program/program.go @@ -1,11 +1,5 @@ -// Package program is the execution-owned intermediate representation: the -// identity every lowered record carries, independent of the syntax tree it -// was lowered from. Ids are indices into the owning graph's tables, dense and -// assigned in declaration order, so two lowerings of one model agree on them; -// zero is "none". A graph's ids are local to it, and a GraphID tells graphs apart. -// -// The package reads no syntax: lower is the one package that reads the tree and -// writes these records, and executors consume them. +// Package program is the execution-owned IR: the identity every lowered record +// carries independent of the syntax tree, assigned by lower and read by executors. package program import "encoding/hex" @@ -34,9 +28,8 @@ type PseudoID uint32 // TransID indexes a transition of a state machine graph. type TransID uint32 -// GraphID identifies a lowered graph by the digest of its canonical encoding, so -// two lowerings of one model yield one id and an edit to the model moves it. The -// encoder that computes it is not written yet; a zero GraphID is "not computed". +// GraphID is the digest of a graph's canonical encoding, the same for two lowerings +// of one model; its encoder is not written yet, and a zero GraphID is "not computed". type GraphID [16]byte // IsZero reports whether the id was never computed. From 200b3688492c8a602d4a6ddcb33711ee5279188f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:36:19 +0000 Subject: [PATCH 3/5] feat(lower): assign graph-local ids and origins to action and state graphs Every node of an ActionGraph and every state, region, pseudostate and transition of a StateGraph gets a dense id in declaration order, an Origin naming it the way messages do, and a Provenance mapping the ids back to the declarations. Additive: no consumer reads the new fields yet. Pins: ids dense, in order, deterministic across two lowerings, Origin.Path unique per graph, and Origin.String() equal to nodeDescription over every action node of the conformance fixtures. Co-Authored-By: jason.han --- .../exec/runtime/origin_description_test.go | 116 ++++++ internal/ir/lower/action_graph.go | 24 +- internal/ir/lower/action_inherited_test.go | 6 +- internal/ir/lower/action_nodes.go | 1 + internal/ir/lower/action_subflow.go | 1 + internal/ir/lower/block_graph.go | 2 + internal/ir/lower/identity.go | 376 ++++++++++++++++++ internal/ir/lower/identity_test.go | 258 ++++++++++++ internal/ir/lower/state_graph.go | 26 ++ internal/ir/program/origin.go | 2 +- 10 files changed, 807 insertions(+), 5 deletions(-) create mode 100644 internal/exec/runtime/origin_description_test.go create mode 100644 internal/ir/lower/identity.go create mode 100644 internal/ir/lower/identity_test.go diff --git a/internal/exec/runtime/origin_description_test.go b/internal/exec/runtime/origin_description_test.go new file mode 100644 index 0000000000..8403024b7c --- /dev/null +++ b/internal/exec/runtime/origin_description_test.go @@ -0,0 +1,116 @@ +package runtime + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/tests/fixtures" +) + +// TestOriginStringMatchesNodeDescription pins program.Origin.String() to what +// nodeDescription says of the same node, over every action the conformance fixtures lower. +func TestOriginStringMatchesNodeDescription(t *testing.T) { + conformanceDir := filepath.Join("testdata", "conformance") + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("failed to read conformance directory: %v", err) + } + checked := 0 + for _, entry := range entries { + if !isConformanceCase(entry.Name()) { + continue + } + checked += originDescriptionsOfCase(t, conformanceDir, entry.Name()) + } + if checked == 0 { + t.Fatal("no action node was checked") + } + t.Logf("checked %d action nodes", checked) +} + +// originDescriptionsOfCase lowers every action a fixture declares and checks each +// node's origin, reporting how many nodes it checked. +func originDescriptionsOfCase(t *testing.T, conformanceDir, fileName string) int { + t.Helper() + caseName := strings.TrimSuffix(fileName, ".expected.json") + path := fixtures.ModelPath(conformanceDir, caseName) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + expectedData, err := os.ReadFile(filepath.Join(conformanceDir, fileName)) + if err != nil { + t.Fatalf("failed to read expectation of %s: %v", caseName, err) + } + var expected ExpectedOutcome + if err := json.Unmarshal(expectedData, &expected); err != nil { + t.Fatalf("%s: failed to parse expected.json: %v", caseName, err) + } + src := source.New(path, data) + file := parser.New(src).ParseFile() + idx, _ := indexCaseDocuments(t, conformanceDir, src, file, expected) + resolver := resolve.New(idx) + checked := 0 + var visit func(scope *symbols.Scope) + visit = func(scope *symbols.Scope) { + for _, sym := range scope.Members() { + if declaresAction(sym.Decl) { + graph, _ := lower.ToActionGraphWith(sym.Decl, DeclScope(sym), resolver) + checked += checkOriginDescriptions(t, caseName, graph) + } + } + for _, child := range scope.Children() { + visit(child) + } + } + visit(idx.DocumentRoot(path)) + return checked +} + +func declaresAction(decl ast.Node) bool { + switch d := decl.(type) { + case *ast.Definition: + return d.Kind == ast.DefAction + case *ast.Usage: + return d.Kind == ast.UsageAction + } + return false +} + +// checkOriginDescriptions checks every node of a graph and of the block graphs +// its bodies state, reporting how many it checked. +func checkOriginDescriptions(t *testing.T, caseName string, graph *lower.ActionGraph) int { + t.Helper() + if graph == nil { + return 0 + } + checked := 0 + for _, node := range graph.Nodes { + id, ok := graph.ID[node] + if !ok { + t.Errorf("%s: %s has no id", caseName, nodeDescription(node)) + continue + } + if got, want := graph.Origins[id].String(), nodeDescription(node); got != want { + t.Errorf("%s: Origin.String() = %q, nodeDescription = %q", caseName, got, want) + } + checked++ + } + for _, body := range graph.Bodies { + for _, stmt := range body { + if block, ok := stmt.(lower.Block); ok { + checked += checkOriginDescriptions(t, caseName, block.Graph) + } + } + } + return checked +} diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 4806ee2aa0..8fb715c808 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + "github.com/Open-MBEE/OpenSysML/internal/ir/program" "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" @@ -36,6 +37,18 @@ type ActionGraph struct { // Nodes in the graph (InitialNode, FinalNode, ExecutionNode, etc.) Nodes []ast.Node + // ID is each node's graph-local id, dense from 1 in the order of Nodes. + ID map[ast.Node]program.NodeID + + // Origins is each node's origin by id; Origins[0] is unused. + Origins []program.Origin + + // Kinds is each node's kind by id; Kinds[0] is unused. + Kinds []program.NodeKind + + // Provenance maps the ids back to the declarations they were lowered from. + Provenance Provenance + // Edges: source node → successions in declaration order. Edges map[ast.Node][]ActionEdge @@ -946,15 +959,20 @@ func lowerActionFlowWithTyping(members []ast.Node, scope *symbols.Scope, resolve func lowerActionFlowWithTypingAndAncestors( members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool, ancestors []ast.Node, -) (*ActionGraph, error) { +) (graph *ActionGraph, err error) { + defer func() { + if graph != nil { + graph.assignIDs() + } + }() generals, cyclic := resolve.ActionGeneralization(scope, typing) if cyclic { - graph := newActionGraph(scope) + graph = newActionGraph(scope) graph.resolver = resolver graph.lowering = actionLoweringAncestors(scope, ancestors) return graph, fmt.Errorf("%w: %s", ErrCyclicSpecialization, actionDescription(scope)) } - graph, err := collectActionNodesWithAncestors(members, scope, resolver, ancestors) + graph, err = collectActionNodesWithAncestors(members, scope, resolver, ancestors) if err != nil { return graph, err } diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 2d5682c505..b79c731f79 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -267,9 +267,13 @@ func TestToActionGraphInheritedActionNodeCycleTerminates(t *testing.T) { action def Derived :> A { first start then missing; } ` derived, scope, _ := inheritedActionDecl(t, src, "Derived") - if _, err := ToActionGraph(derived, scope); !errors.Is(err, ErrCyclicSpecialization) { + graph, err := ToActionGraph(derived, scope) + if !errors.Is(err, ErrCyclicSpecialization) { t.Fatalf("ToActionGraph error = %v, want ErrCyclicSpecialization", err) } + if graph != nil && (graph.ID == nil || len(graph.Origins) != len(graph.Nodes)+1) { + t.Errorf("graph returned with the error has no ids: ID %v, %d origins for %d nodes", graph.ID, len(graph.Origins), len(graph.Nodes)) + } } func inheritedActionDecl(t *testing.T, src, name string) (ast.Node, *symbols.Scope, *ast.RootNamespace) { diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index f6b2bf16c5..e51b8d0845 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -56,6 +56,7 @@ func ToActionInterface(actionDecl ast.Node, scope *symbols.Scope) (*ActionGraph, graph := newActionGraph(scope) graph.Attributes = lowerAttributes(members, scope) graph.Parameters = lowerParameters(members, scope) + graph.assignIDs() return graph, nil } diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 2b97dcbba8..f2f08a3deb 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -422,6 +422,7 @@ func lowerTerminateNode( if blockGraph == nil { blockGraph = newActionGraph(scope) blockGraph.resolver = graph.resolver + blockGraph.assignIDs() } if err != nil { blockGraph.Invalid = err diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 356997245d..0e70c77106 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -159,6 +159,7 @@ func lowerStatedBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope, if err != nil { if graph == nil { graph = newActionGraph(scope) + graph.assignIDs() } graph.Invalid = err StartFlow(graph) @@ -228,6 +229,7 @@ func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, resolver *reso graph.Edges[graph.Nodes[i]] = []ActionEdge{{Source: graph.Nodes[i], Target: graph.Nodes[i+1], DeclaredOrder: true}} } recordBlockNodes(graph) + graph.assignIDs() return graph } diff --git a/internal/ir/lower/identity.go b/internal/ir/lower/identity.go new file mode 100644 index 0000000000..e5f3af4cc3 --- /dev/null +++ b/internal/ir/lower/identity.go @@ -0,0 +1,376 @@ +package lower + +import ( + "fmt" + "sort" + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/ir/program" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Provenance maps a graph's ids back to the declarations its records were +// lowered from; index zero of every table is "none". +type Provenance struct { + nodes []ast.Node + states []*ast.StateNode + regions []*ast.StateRegion + pseudos []*ast.PseudostateNode + trans []*Transition +} + +// Node is the declaration behind a node id, nil for none. +func (p *Provenance) Node(id program.NodeID) ast.Node { + if id == 0 || int(id) >= len(p.nodes) { + return nil + } + return p.nodes[id] +} + +// State is the declaration behind a state id, nil for none. +func (p *Provenance) State(id program.StateID) *ast.StateNode { + if id == 0 || int(id) >= len(p.states) { + return nil + } + return p.states[id] +} + +// Region is the declaration behind a region id, nil for none. +func (p *Provenance) Region(id program.RegionID) *ast.StateRegion { + if id == 0 || int(id) >= len(p.regions) { + return nil + } + return p.regions[id] +} + +// Pseudostate is the declaration behind a pseudostate id, nil for none. +func (p *Provenance) Pseudostate(id program.PseudoID) *ast.PseudostateNode { + if id == 0 || int(id) >= len(p.pseudos) { + return nil + } + return p.pseudos[id] +} + +// Transition is the record behind a transition id, nil for none. +func (p *Provenance) Transition(id program.TransID) *Transition { + if id == 0 || int(id) >= len(p.trans) { + return nil + } + return p.trans[id] +} + +// tableIndex is the id of the record about to be appended to a table of n entries. +func tableIndex(n int) uint32 { + return uint32(n) // #nosec G115 -- a graph has far fewer than 2^32 records +} + +// originPaths keeps the paths handed out within one graph unique. +type originPaths map[string]bool + +// unique returns base, or base with the first `[k]` that no earlier origin took. +func (p originPaths) unique(base string) string { + path := base + for k := 1; p[path]; k++ { + path = base + "[" + strconv.Itoa(k) + "]" + } + p[path] = true + return path +} + +// originPath is a declaration's path: its qualified name, else its owner's +// qualified name with its name or, unnamed, its kind word (`A::b`, `A#if`). +func originPath(scope *symbols.Scope, sym *symbols.Symbol, name, kind string) string { + if sym != nil { + if fqn := symbols.FQNOf(sym); fqn != "" { + return fqn + } + } + var owner string + if scope != nil { + owner = symbols.FQNOf(scope.Owner()) + } + if name != "" { + if owner == "" { + return name + } + return owner + "::" + name + } + return owner + "#" + kind +} + +// originName is the name a message calls a node by, "" when it has none. +func originName(node ast.Node) string { + switch n := node.(type) { + case *ast.InitialNode: + return n.Name() + case *ast.FinalNode: + return "done" + case *ast.ForkNode: + return n.Name + case *ast.JoinNode: + return n.Name + case *ast.MergeNode: + return n.Name + case *ast.DecisionNode: + return n.Name + case *ast.ActionExecutionNode: + return n.Name + case *ast.StateNode: + return n.Name + case *ast.Usage: + if name, _ := ast.EffectiveName(n); name != "" { + return name + } + return n.Ident.ShortName + case *ast.Definition: + if n.Ident.Name != "" { + return n.Ident.Name + } + return n.Ident.ShortName + } + return "" +} + +// nodeKind is what a node of the graph does when a token reaches it. +func (g *ActionGraph) nodeKind(node ast.Node) program.NodeKind { + if g.StatementRuns[node] { + return program.NodeStatement + } + switch n := node.(type) { + case *ast.InitialNode: + return program.NodeInitial + case *ast.FinalNode: + return program.NodeFinal + case *ast.ForkNode: + return program.NodeFork + case *ast.JoinNode: + return program.NodeJoin + case *ast.MergeNode: + return program.NodeMerge + case *ast.DecisionNode: + return program.NodeDecision + case *ast.ActionExecutionNode: + return program.NodeExecution + case *ast.PerformActionNode: + return program.NodePerform + case *ast.AssignmentActionNode: + return program.NodeAssign + case *ast.WhileLoopActionNode: + return program.NodeLoop + case *ast.IfActionNode: + return program.NodeIf + case *ast.Usage: + if _, ok := g.Accepts[node]; ok || n.IsAccept { + return program.NodeAccept + } + return program.NodeExecution + } + return program.NodeStatement +} + +// assignIDs gives every node of the graph its id, dense from 1 in the order of +// Nodes, and the origin and kind beside it; a graph lowered twice gets the same ids. +func (g *ActionGraph) assignIDs() { + n := len(g.Nodes) + 1 + g.ID = make(map[ast.Node]program.NodeID, len(g.Nodes)) + g.Origins = make([]program.Origin, 1, n) + g.Kinds = make([]program.NodeKind, 1, n) + g.Provenance.nodes = make([]ast.Node, 1, n) + paths := originPaths{} + for _, node := range g.Nodes { + if _, seen := g.ID[node]; seen { + continue + } + id := program.NodeID(tableIndex(len(g.Provenance.nodes))) + g.ID[node] = id + g.Provenance.nodes = append(g.Provenance.nodes, node) + kind := g.nodeKind(node) + g.Kinds = append(g.Kinds, kind) + g.Origins = append(g.Origins, g.nodeOrigin(node, kind, paths)) + } +} + +func (g *ActionGraph) nodeOrigin(node ast.Node, kind program.NodeKind, paths originPaths) program.Origin { + scope := g.DeclScope(node) + var sym *symbols.Symbol + if scope != nil { + sym = scope.MemberDeclaring(node) + } + name := originName(node) + return program.Origin{ + Doc: symbols.DocNameOf(scope), + Span: node.Span(), + Path: paths.unique(originPath(scope, sym, name, kind.String())), + Kind: fmt.Sprintf("%T", node), + Name: name, + } +} + +// assignIDs gives every state, region, pseudostate and transition of the graph +// its id, dense from 1 in declaration order, and the origin beside it. +func (g *StateGraph) assignIDs() { + paths := originPaths{} + g.assignStateIDs(paths) + g.assignRegionIDs(paths) + g.assignPseudoIDs(paths) + g.assignTransIDs(paths) +} + +func (g *StateGraph) assignStateIDs(paths originPaths) { + n := len(g.States) + 1 + g.ID = make(map[*ast.StateNode]program.StateID, len(g.States)) + g.Origins = make([]program.Origin, 1, n) + g.Provenance.states = make([]*ast.StateNode, 1, n) + for _, state := range g.States { + if _, seen := g.ID[state]; seen { + continue + } + g.ID[state] = program.StateID(tableIndex(len(g.Provenance.states))) + g.Provenance.states = append(g.Provenance.states, state) + scope := g.StateScopes[state] + var sym *symbols.Symbol + if scope != nil { + sym = scope.Owner() + } + if sym == nil { + if declared := g.declaredIn[state]; declared != nil { + scope, sym = declared, declared.MemberDeclaring(state) + } + } + g.Origins = append(g.Origins, g.vertexOrigin(state, scope, sym, state.Name, paths)) + } +} + +// regionsInOrder lists the graph's regions once each: the machine's own, then +// each state's in state order. +func (g *StateGraph) regionsInOrder() []*ast.StateRegion { + seen := make(map[*ast.StateRegion]bool) + var out []*ast.StateRegion + add := func(regions []*ast.StateRegion) { + for _, region := range regions { + if !seen[region] { + seen[region] = true + out = append(out, region) + } + } + } + add(g.TopRegions) + for _, state := range g.States { + add(g.CompositeStates[state]) + } + return out +} + +func (g *StateGraph) assignRegionIDs(paths originPaths) { + regions := g.regionsInOrder() + g.RegionID = make(map[*ast.StateRegion]program.RegionID, len(regions)) + g.RegionOrigins = make([]program.Origin, 1, len(regions)+1) + g.Provenance.regions = make([]*ast.StateRegion, 1, len(regions)+1) + for _, region := range regions { + g.RegionID[region] = program.RegionID(tableIndex(len(g.Provenance.regions))) + g.Provenance.regions = append(g.Provenance.regions, region) + scope := g.regionScopeOf[region] + g.RegionOrigins = append(g.RegionOrigins, program.Origin{ + Doc: symbols.DocNameOf(scope), + Span: region.Span(), + Path: paths.unique(originPath(scope, nil, region.Name, "region")), + Kind: "region", + Name: region.Name, + }) + } +} + +func (g *StateGraph) assignPseudoIDs(paths originPaths) { + n := len(g.Pseudostates) + 1 + g.PseudoID = make(map[*ast.PseudostateNode]program.PseudoID, len(g.Pseudostates)) + g.PseudoOrigins = make([]program.Origin, 1, n) + g.Provenance.pseudos = make([]*ast.PseudostateNode, 1, n) + for _, ps := range g.Pseudostates { + if _, seen := g.PseudoID[ps]; seen { + continue + } + g.PseudoID[ps] = program.PseudoID(tableIndex(len(g.Provenance.pseudos))) + g.Provenance.pseudos = append(g.Provenance.pseudos, ps) + scope := g.declaredIn[ps] + var sym *symbols.Symbol + if scope != nil { + sym = scope.MemberDeclaring(ps) + } + g.PseudoOrigins = append(g.PseudoOrigins, g.vertexOrigin(ps, scope, sym, ps.Name, paths)) + } +} + +func (g *StateGraph) vertexOrigin(decl ast.Node, scope *symbols.Scope, sym *symbols.Symbol, name string, paths originPaths) program.Origin { + kind := VertexKind(decl) + return program.Origin{ + Doc: g.DocOf(decl), + Span: decl.Span(), + Path: paths.unique(originPath(scope, sym, name, kind)), + Kind: kind, + Name: name, + } +} + +// transitionsInOrder lists the graph's transitions once each: those leaving each +// state in state order, then each pseudostate's, then any other source's by position. +func (g *StateGraph) transitionsInOrder() []*Transition { + var out []*Transition + seen := make(map[ast.Node]bool) + add := func(source ast.Node) { + if seen[source] { + return + } + seen[source] = true + out = append(out, g.Transitions[source]...) + } + for _, state := range g.States { + add(state) + } + for _, ps := range g.Pseudostates { + add(ps) + } + var rest []ast.Node + for source := range g.Transitions { + if !seen[source] { + rest = append(rest, source) + } + } + sort.Slice(rest, func(i, j int) bool { return rest[i].Span().Offset < rest[j].Span().Offset }) + for _, source := range rest { + add(source) + } + return out +} + +func (g *StateGraph) assignTransIDs(paths originPaths) { + trans := g.transitionsInOrder() + g.TransID = make(map[*Transition]program.TransID, len(trans)) + g.TransOrigins = make([]program.Origin, 1, len(trans)+1) + g.Provenance.trans = make([]*Transition, 1, len(trans)+1) + for _, t := range trans { + if _, seen := g.TransID[t]; seen { + continue + } + g.TransID[t] = program.TransID(tableIndex(len(g.Provenance.trans))) + g.Provenance.trans = append(g.Provenance.trans, t) + var span source.Span + var sym *symbols.Symbol + if t.Decl != nil { + span = t.Decl.Span() + if t.Scope != nil { + sym = t.Scope.MemberDeclaring(t.Decl) + } + } else if t.Source != nil { + span = t.Source.Span() + } + g.TransOrigins = append(g.TransOrigins, program.Origin{ + Doc: g.DocOf(t.Decl), + Span: span, + Path: paths.unique(originPath(t.Scope, sym, t.Name, "transition")), + Kind: "transition", + Name: t.Name, + }) + } +} diff --git a/internal/ir/lower/identity_test.go b/internal/ir/lower/identity_test.go new file mode 100644 index 0000000000..b2dd56bdb0 --- /dev/null +++ b/internal/ir/lower/identity_test.go @@ -0,0 +1,258 @@ +package lower + +import ( + "reflect" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/program" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +const identityActionSrc = ` + action brew { + first start; + action grind; + action pour; + fork f; + join j; + if x > 1 { action a1; } + if x > 2 { action a2; } + assign y := 1; + assign y := 2; + first start then grind; + first grind then pour; + first pour then done; + } +` + +// identityActionGraph lowers the first action usage of src within its document, +// so that origins carry the document name and qualified paths. +func identityActionGraph(t *testing.T, src string) *ActionGraph { + t.Helper() + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse errors: %v", p.Diagnostics) + } + var usage *ast.Usage + for _, member := range root.Members { + if m, ok := member.(*ast.Membership); ok { + if u, ok := m.Member.(*ast.Usage); ok && u.Kind == ast.UsageAction { + usage = u + break + } + } + } + if usage == nil { + t.Fatal("no action usage found") + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + scope := idx.DocumentRoot("test.sysml").ChildFor(usage) + if scope == nil { + t.Fatal("scope for action usage missing") + } + graph, err := ToActionGraphWith(usage, scope, resolve.New(idx)) + if err != nil { + t.Fatalf("ToActionGraphWith: %v", err) + } + return graph +} + +func TestAssignIDs_ActionDenseInDeclarationOrder(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + if len(graph.Nodes) < 6 { + t.Fatalf("fixture lowers %d nodes, want several", len(graph.Nodes)) + } + if got := len(graph.Origins); got != len(graph.Nodes)+1 { + t.Fatalf("len(Origins) = %d, want %d", got, len(graph.Nodes)+1) + } + if len(graph.Kinds) != len(graph.Origins) || len(graph.ID) != len(graph.Nodes) { + t.Fatalf("Kinds %d, ID %d, Nodes %d", len(graph.Kinds), len(graph.ID), len(graph.Nodes)) + } + for i, node := range graph.Nodes { + id := graph.ID[node] + if id != program.NodeID(i+1) { + t.Errorf("node %d has id %d, want %d", i, id, i+1) + } + if graph.Provenance.Node(id) != node { + t.Errorf("Provenance.Node(%d) is not the node", id) + } + if graph.Kinds[id] == program.NodeNone { + t.Errorf("node %d has no kind", id) + } + if origin := graph.Origins[id]; origin.Doc != "test.sysml" || origin.Span != node.Span() { + t.Errorf("node %d origin = %#v", id, origin) + } + } + if graph.Provenance.Node(0) != nil || graph.Provenance.Node(program.NodeID(len(graph.Nodes)+1)) != nil { + t.Error("ids outside the table must map to nil") + } +} + +func TestAssignIDs_ActionOriginsNameWhatMessagesName(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + byName := make(map[string]program.NodeID) + for _, node := range graph.Nodes { + byName[graph.Origins[graph.ID[node]].Name] = graph.ID[node] + } + grind := graph.Origins[byName["grind"]] + if grind.String() != "node grind" || grind.Kind != "*ast.Usage" || grind.Path != "brew::grind" || grind.Doc != "test.sysml" { + t.Errorf("grind origin = %#v", grind) + } + if graph.Kinds[1] != program.NodeInitial || graph.Kinds[byName["f"]] != program.NodeFork || graph.Kinds[byName["grind"]] != program.NodeExecution { + t.Errorf("kinds: initial %v, fork %v, grind %v", graph.Kinds[1], graph.Kinds[byName["f"]], graph.Kinds[byName["grind"]]) + } + done := graph.Origins[byName["done"]] + if done.String() != "node done" || done.Kind != "*ast.FinalNode" || graph.Kinds[byName["done"]] != program.NodeFinal { + t.Errorf("done origin = %#v", done) + } + for id := 1; id < len(graph.Origins); id++ { + if origin := graph.Origins[id]; origin.Name == "" && origin.String() != "the "+origin.Kind { + t.Errorf("unnamed node %d renders %q", id, origin.String()) + } + } +} + +func TestAssignIDs_ActionPathUniquePerGraph(t *testing.T) { + graph := identityActionGraph(t, identityActionSrc) + paths := make(map[string]program.NodeID) + unnamedKinds := make(map[string]int) + for id := 1; id < len(graph.Origins); id++ { + origin := graph.Origins[id] + if origin.Path == "" { + t.Errorf("node %d has an empty path", id) + } + if prev, dup := paths[origin.Path]; dup { + t.Errorf("nodes %d and %d share path %q", prev, id, origin.Path) + } + paths[origin.Path] = program.NodeID(id) + if origin.Name == "" { + unnamedKinds[origin.Kind]++ + } + } + // The fixture must make uniqueness do work: two unnamed nodes of one kind. + exercised := false + for _, n := range unnamedKinds { + exercised = exercised || n > 1 + } + if !exercised { + t.Fatalf("fixture has no two unnamed nodes of one kind: %v", unnamedKinds) + } +} + +func TestAssignIDs_ActionDeterministicAcrossLowerings(t *testing.T) { + first := identityActionGraph(t, identityActionSrc) + second := identityActionGraph(t, identityActionSrc) + if !reflect.DeepEqual(first.Origins, second.Origins) { + t.Errorf("origins differ:\n%+v\n%+v", first.Origins, second.Origins) + } + if !reflect.DeepEqual(first.Kinds, second.Kinds) { + t.Errorf("kinds differ: %v vs %v", first.Kinds, second.Kinds) + } + for i := range first.Nodes { + if first.ID[first.Nodes[i]] != second.ID[second.Nodes[i]] { + t.Errorf("node %d: ids %d and %d", i, first.ID[first.Nodes[i]], second.ID[second.Nodes[i]]) + } + } +} + +func identityStateGraph(t *testing.T) *StateGraph { + t.Helper() + graph, err := ToStateGraph(stateDefinitionIn(t, forkMachine( + `state a; state b;`, + `state c;`, + `transition first split then a; + transition first split then c; + transition maintain first a then b;`, + )), nil) + if err != nil { + t.Fatalf("ToStateGraph: %v", err) + } + return graph +} + +func TestAssignIDs_StateTablesDenseInDeclarationOrder(t *testing.T) { + graph := identityStateGraph(t) + if len(graph.States) < 4 || len(graph.Pseudostates) < 1 || len(graph.RegionID) < 2 || len(graph.TransID) < 3 { + t.Fatalf("fixture lowers %d states, %d pseudostates, %d regions, %d transitions", + len(graph.States), len(graph.Pseudostates), len(graph.RegionID), len(graph.TransID)) + } + if len(graph.Origins) != len(graph.States)+1 || len(graph.PseudoOrigins) != len(graph.Pseudostates)+1 || + len(graph.RegionOrigins) != len(graph.RegionID)+1 || len(graph.TransOrigins) != len(graph.TransID)+1 { + t.Fatal("every origin table is one longer than its records") + } + for i, state := range graph.States { + id := graph.ID[state] + if id != program.StateID(i+1) || graph.Provenance.State(id) != state { + t.Errorf("state %s: id %d at %d", state.Name, id, i) + } + if origin := graph.Origins[id]; origin.Name != state.Name || origin.Kind != "state" || origin.Span != state.Span() { + t.Errorf("state %s origin = %+v", state.Name, origin) + } + } + for i, ps := range graph.Pseudostates { + id := graph.PseudoID[ps] + if id != program.PseudoID(i+1) || graph.Provenance.Pseudostate(id) != ps { + t.Errorf("pseudostate %s: id %d at %d", ps.Name, id, i) + } + } + for region, id := range graph.RegionID { + if graph.Provenance.Region(id) != region || graph.RegionOrigins[id].Kind != "region" { + t.Errorf("region %s: id %d", region.Name, id) + } + } + for i, region := range graph.TopRegions { + if graph.RegionID[region] != program.RegionID(i+1) { + t.Errorf("top region %d has id %d", i, graph.RegionID[region]) + } + } + seen := 0 + for _, state := range graph.States { + for _, tr := range graph.Transitions[state] { + seen++ + id := graph.TransID[tr] + if id != program.TransID(seen) || graph.Provenance.Transition(id) != tr { + t.Errorf("transition %d from %s has id %d", seen, state.Name, id) + } + } + } + if origin := graph.TransOrigins[graph.TransID[graph.Transitions[stateNamed(graph, "a")][0]]]; origin.Name != "maintain" || origin.Kind != "transition" { + t.Errorf("named transition origin = %+v", origin) + } +} + +func TestAssignIDs_StatePathUniquePerGraph(t *testing.T) { + graph := identityStateGraph(t) + paths := make(map[string]bool) + check := func(table string, origins []program.Origin) { + for id := 1; id < len(origins); id++ { + path := origins[id].Path + if path == "" || paths[path] { + t.Errorf("%s %d: path %q empty or taken", table, id, path) + } + paths[path] = true + } + } + check("state", graph.Origins) + check("region", graph.RegionOrigins) + check("pseudostate", graph.PseudoOrigins) + check("transition", graph.TransOrigins) +} + +func TestAssignIDs_StateDeterministicAcrossLowerings(t *testing.T) { + first, second := identityStateGraph(t), identityStateGraph(t) + for name, pair := range map[string][2][]program.Origin{ + "states": {first.Origins, second.Origins}, + "regions": {first.RegionOrigins, second.RegionOrigins}, + "pseudostates": {first.PseudoOrigins, second.PseudoOrigins}, + "transitions": {first.TransOrigins, second.TransOrigins}, + } { + if !reflect.DeepEqual(pair[0], pair[1]) { + t.Errorf("%s differ:\n%+v\n%+v", name, pair[0], pair[1]) + } + } +} diff --git a/internal/ir/lower/state_graph.go b/internal/ir/lower/state_graph.go index 7221b6455c..017e32dc84 100644 --- a/internal/ir/lower/state_graph.go +++ b/internal/ir/lower/state_graph.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/Open-MBEE/OpenSysML/internal/ir/program" "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" @@ -102,6 +103,30 @@ type StateGraph struct { // States in the machine (flat list, includes nested) States []*ast.StateNode + // ID is each state's graph-local id, dense from 1 in the order of States. + ID map[*ast.StateNode]program.StateID + + // Origins is each state's origin by id; Origins[0] is unused. + Origins []program.Origin + + // RegionID is each region's id, the machine's own regions first, then each + // state's in state order; RegionOrigins is its origin by id. + RegionID map[*ast.StateRegion]program.RegionID + RegionOrigins []program.Origin + + // PseudoID is each pseudostate's id in the order of Pseudostates; + // PseudoOrigins is its origin by id. + PseudoID map[*ast.PseudostateNode]program.PseudoID + PseudoOrigins []program.Origin + + // TransID is each transition's id, those leaving each state in state order, + // then each pseudostate's; TransOrigins is its origin by id. + TransID map[*Transition]program.TransID + TransOrigins []program.Origin + + // Provenance maps the ids back to the records they were lowered from. + Provenance Provenance + // Pseudostates of the machine in declaration order. Not keyed by name: // sibling regions may declare same-named pseudostates. Pseudostates []*ast.PseudostateNode @@ -417,6 +442,7 @@ func ToStateGraphWithEndpoints(stateMachineDecl ast.Node, scope *symbols.Scope, return nil, err } + graph.assignIDs() return graph, nil } diff --git a/internal/ir/program/origin.go b/internal/ir/program/origin.go index 9f6f977202..c58cabd5e1 100644 --- a/internal/ir/program/origin.go +++ b/internal/ir/program/origin.go @@ -19,7 +19,7 @@ type Origin struct { Kind string // Name is the declaration's name as messages use it, "" when it has none. Name string - // Docs is the declaration's documentation prose, "" when it has none. + // Docs is the declaration's documentation prose; "" when it has none or the lowering read no source text. Docs string } From 66a4db9e0c6935499b0f7dab0dfc376381d9577d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:44:42 +0000 Subject: [PATCH 4/5] fix(lower): assign case-flow ids after its steps join the graph An unstated case flow gains its performed actions and statement runs after lowerActionFlow returns, so the ids are assigned once those are in place. Co-Authored-By: jason.han --- internal/ir/lower/case_body.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ir/lower/case_body.go b/internal/ir/lower/case_body.go index e0e63fe513..938cac9a69 100644 --- a/internal/ir/lower/case_body.go +++ b/internal/ir/lower/case_body.go @@ -81,6 +81,7 @@ func caseSteps(owner ast.Node, body []ast.Node, scope *symbols.Scope, resolver * } addCasePerformNodes(graph, members, scope) addCaseStatementRuns(graph, body, statementRuns) + graph.assignIDs() graph.UnstatedCaseFlow = true StartFlow(graph) flow := Block{Node: owner, Scope: scope, Graph: graph, Own: true, Stated: true} From 057e4f1187f4553a3adf0f65147401fefe2b7bad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:44:42 +0000 Subject: [PATCH 5/5] fix(lower): region origins read the scope the region was declared in A region written in the machine's own body records its scope in declaredIn, not regionScopeOf, so its origin lost the document and owner. A terminate usage is a terminate node. Co-Authored-By: jason.han --- internal/ir/lower/identity.go | 6 ++++ internal/ir/lower/identity_test.go | 58 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/internal/ir/lower/identity.go b/internal/ir/lower/identity.go index e5f3af4cc3..41bbc6af13 100644 --- a/internal/ir/lower/identity.go +++ b/internal/ir/lower/identity.go @@ -165,6 +165,9 @@ func (g *ActionGraph) nodeKind(node ast.Node) program.NodeKind { if _, ok := g.Accepts[node]; ok || n.IsAccept { return program.NodeAccept } + if n.IsTerminate { + return program.NodeTerminate + } return program.NodeExecution } return program.NodeStatement @@ -272,6 +275,9 @@ func (g *StateGraph) assignRegionIDs(paths originPaths) { g.RegionID[region] = program.RegionID(tableIndex(len(g.Provenance.regions))) g.Provenance.regions = append(g.Provenance.regions, region) scope := g.regionScopeOf[region] + if scope == nil { + scope = g.declaredIn[region] + } g.RegionOrigins = append(g.RegionOrigins, program.Origin{ Doc: symbols.DocNameOf(scope), Span: region.Span(), diff --git a/internal/ir/lower/identity_test.go b/internal/ir/lower/identity_test.go index b2dd56bdb0..cb0c2e110d 100644 --- a/internal/ir/lower/identity_test.go +++ b/internal/ir/lower/identity_test.go @@ -2,6 +2,7 @@ package lower import ( "reflect" + "strings" "testing" "github.com/Open-MBEE/OpenSysML/internal/ir/program" @@ -256,3 +257,60 @@ func TestAssignIDs_StateDeterministicAcrossLowerings(t *testing.T) { } } } + +func TestAssignIDs_CaseFlowCoversNodesAddedAfterLowering(t *testing.T) { + body := caseBodyOf(t, ` + analysis def Among { + subject s : S; + if s.ready { + assign s.x := 1; + out r : Integer = s.x; + } + perform action p : P; + return r : Integer = s.x; + } + `) + flow, ok := body[0].(Block) + if !ok || flow.Graph == nil || len(flow.Graph.Nodes) != 2 { + t.Fatalf("body[0] = %#v, want the case flow of two nodes", body[0]) + } + graph := flow.Graph + if len(graph.ID) != len(graph.Nodes) || len(graph.Origins) != len(graph.Nodes)+1 { + t.Fatalf("%d ids and %d origins for %d nodes", len(graph.ID), len(graph.Origins), len(graph.Nodes)) + } + for i, node := range graph.Nodes { + if graph.ID[node] != program.NodeID(i+1) { + t.Errorf("node %d has id %d", i, graph.ID[node]) + } + } + if graph.Kinds[1] != program.NodeIf || graph.Kinds[2] != program.NodeExecution { + t.Errorf("kinds = %v, want the branch then the performed action", graph.Kinds[1:]) + } +} + +func TestAssignIDs_RegionOriginsCarryDocumentAndOwner(t *testing.T) { + src := forkMachine(`state a;`, `state c;`, `transition first split then a; transition first split then c;`) + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse errors: %v", p.Diagnostics) + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + machine := idx.LookupQualified("test::Machine") + if len(machine) != 1 || machine[0].Scope == nil { + t.Fatalf("test::Machine resolves to %d symbols", len(machine)) + } + graph, err := ToStateGraph(machine[0].Decl, machine[0].Scope) + if err != nil { + t.Fatalf("ToStateGraph: %v", err) + } + if len(graph.RegionOrigins) < 3 { + t.Fatalf("%d region origins, want the two regions of work", len(graph.RegionOrigins)-1) + } + for id := 1; id < len(graph.RegionOrigins); id++ { + origin := graph.RegionOrigins[id] + if origin.Doc != "test.sysml" || !strings.HasPrefix(origin.Path, "test::Machine") { + t.Errorf("region %d origin = %#v", id, origin) + } + } +}